Skip to content
Monomer

Format dashboard table cells

A TABLE widget shows its query result with the host’s default formatting: the metric’s saved display format, or the exact digits a number needs. To color, label, or annotate cells, give the widget an optional module. Its TypeScript or JavaScript source computes presentation only: a cell’s text, colors, emphasis, alignment, tooltip, or a small static graphic, and a row’s background. The query result itself doesn’t change.

Agents create and edit dashboards; you review the result in the app. With MCP, describe the advertised Dashboard create or draft edit operation using monomer_describe, then read this guide with monomer_docs at /en/dashboards/table-source/index.md; the Dashboard definition schema’s TABLE module.source description points here. Follow Create a dashboard with an agent for the complete create workflow and Review and apply dashboard changes to change an existing dashboard.

The source decidesThe host keeps
Each cell’s displayed text, text color, background, strong emphasis, alignment, and tooltipRaw values, used for sorting and Copy raw value
A static SVG graphic in a cellRows, columns, and headers, as the definition declares them
Each row’s background colorScrolling, cell selection, keyboard navigation, and accessibility

A source can’t add, remove, or reorder rows or columns, run queries, change variables, or respond to clicks. If the default display already reads well, leave module out: metric display formats apply without code.

module has two required fields:

FieldContents
inputsqueries, parameters, and variables arrays. The widget’s queryName result is included automatically, so queries lists only the other, auxiliary queries the source reads. It can’t repeat queryName or name a pivot totals query. parameters and variables list names declared in the dashboard. Use empty arrays when the source needs none.
sourceThe complete module code as one JSON string, including export default. TypeScript or JavaScript, at most 256 KiB in UTF-8 bytes. Imports, require, dynamic imports, and named exports are unavailable.

Auxiliary queries run with the dashboard’s other queries. The table replaces its results only after its primary and auxiliary queries all succeed; until then, it keeps the previous results together with the variable values they were shown with.

Export exactly one defineTable call as the default export. The runtime provides defineTable; don’t import it. Its object has one method:

MethodContract
render(frame)Required and synchronous. Returns an object with an optional row function, an optional cell function, or neither. It runs again whenever the table’s results, declared inputs, or locale change.

The host calls row and cell in batches for the rows on screen, in no fixed order. Both are synchronous. Return plain objects with only the documented fields: getters, unknown fields, null, and Promises are rejected. Build lookups and formatters once in render, then read them in row and cell.

Write render, row, and cell so the same input always gives the same output. Don’t depend on how often or in what order they run, or on which rows are visible.

FieldWhat the source receives
queriesAn immutable map holding the widget’s queryName result and each inputs.queries result. Each result has columns and rows.
parametersRead-only values for names in inputs.parameters: the values the displayed results ran with.
variablesRead-only values for names in inputs.variables, from the displayed results.
localeThe viewer’s display locale, such as en-US. Pass it to Intl.
rowQueryNameThe widget’s queryName. queries[rowQueryName].rows are the rows the table shows.

A row is a flat object keyed by result column name, such as "metric.revenue" or "dimension.orders.region"; SQL results use their declared column names. Check the query’s actual result keys before writing the source. Each value is string | boolean | null. Numbers, including INTEGER, DECIMAL, and FLOAT, arrive as exact decimal strings. Convert with Number only to compare values or choose a color; pass the original string to Intl.NumberFormat to keep every digit.

Each column descriptor has name, logicalType, nullable, and numberFormat. numberFormat holds standard Intl.NumberFormat options from the metric’s saved display format, or null for SQL columns and columns without one.

Time values arrive as strings, already in the Workspace’s Reporting time zone:

logicalTypeWhere it comes fromExample
DATESemantic time columns at day, week, month, quarter, or year grain, and SQL dates"2026-08-03"; a week is its Monday
DATETIMESemantic time columns at hour or finer grain: the Reporting time zone’s wall-clock time, without an offset. Also SQL timestamps without a time zone."2026-08-03T14:00:00.000000"
TIMESTAMPSQL timestamps with a time zone, as UTC instants"2026-08-03T05:00:00.000000Z"

Never parse DATE or DATETIME values with new Date(). It reads a date-only string as midnight UTC and a date and time without an offset in the viewer’s own time zone, so the same row can show a different day or hour to different viewers. Read the parts from the string instead, as in Readable dates. Dashboard Variables that carry a day from these results are DATE Variables, "YYYY-MM-DD"; a TIMESTAMP Variable holds a UTC instant ending in Z.

cell receives one object:

FieldContents
fieldThe cell’s result column key.
valueThe cell’s raw value.
rowThe whole result row.
rowIndexThe row’s position in the query result, starting at 0. It isn’t the position on screen, which changes when a reader sorts.
displayTextExactly the text the host shows for this cell when cell returns undefined. Numbers use the metric’s saved display format, or else the digits their column needs, as in Explore, so 10430427.950000000 reads 10,430,427.95. A null value reads null and an empty string reads (empty string). Other values appear as they are, so a DATE reads 2026-09-21.

Return undefined to keep the host’s display: its text, its alignment for numbers, and its tooltip showing the raw value. Otherwise return a cell view:

FieldContents
textRequired. The visible text, the text Copy displayed text copies, and the cell’s accessible text. "" shows an intentionally empty cell. At most 4,096 UTF-16 code units, unless it is exactly displayText.
colorOptional text color, #RRGGBB or #RRGGBBAA.
backgroundOptional background color in the same form.
emphasisOptional "normal" or "strong".
alignOptional "start", "center", or "end". Without it, numeric columns stay aligned to the end.
tooltipOptional, at most 8,192 UTF-16 code units. It replaces the default tooltip, which shows the raw value.
graphicOptional static SVG image; see Add a static graphic.

To change only the style, keep the host’s text: { text: displayText, background: "#ECF4FE" }. To add to it, build on it, as in `${displayText} ▲`. When text is exactly displayText, the host shows its own text and doesn’t count it against the text limit or the batch output budget, so long text values can be styled too.

row receives { row, rowIndex } and returns undefined or { background }, with a color in the same form. A cell’s own background covers its row’s.

graphic is { svg }: a string holding one self-contained SVG image. The image fills the cell, scaled to fit, in place of the visible text. text stays required and must carry the same information: it is the image’s alternative text and what Copy displayed text copies. For a trend line, put the observed values in text; a tooltip doesn’t replace it.

The host shows the image only if it meets every rule below. It never removes parts of an invalid image; the cell shows its default text instead.

  • The root is a single svg element with xmlns="http://www.w3.org/2000/svg" and a viewBox.
  • Elements are limited to svg, g, defs, path, rect, circle, ellipse, line, polyline, polygon, text, tspan, linearGradient, radialGradient, stop, and clipPath. Scripts, styles, images, use, links, foreignObject, animation, event attributes, external URLs, and external fonts are rejected.
  • Each element accepts its geometry attributes, such as x, y, width, height, rx, cx, cy, r, points, and d, plus id, fill, stroke, stroke-width, the opacity attributes, transform, and clip-path. svg, g, text, and tspan also accept text styles such as font-family, font-size, font-weight, text-anchor, and dominant-baseline.
  • Colors are hex values. fill and stroke also accept none or url(#id) pointing to a gradient in the same image.
  • font-family is system-ui, sans-serif, serif, monospace, or a comma-separated list of them.
  • Numbers are plain and finite, from -1,000,000 to 1,000,000, without units or percentages.
  • An image is at most 32 KiB in UTF-8 bytes, with at most 512 elements and text nodes and 16 levels of nesting.

This bar shows 72% of a target, so its cell text would be 72% of target:

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 12"><rect width="100" height="12" rx="2" fill="#ECEFF3"/><rect width="72" height="12" rx="2" fill="#3870DF"/></svg>

A source runs in an isolated Worker with immutable inputs. The standard language is available, including Array, Object, Map, Set, RegExp, BigInt, and Math, together with Intl and Date under these rules:

  • Intl provides NumberFormat, DateTimeFormat, Collator, PluralRules, ListFormat, RelativeTimeFormat, Segmenter, DisplayNames, and Locale where the viewer’s browser supports them. Pass an explicit, non-empty locale; use frame.locale. Intl.DateTimeFormat needs an explicit timeZone, and its format and formatToParts need an explicit date. Intl.NumberFormat formats decimal strings exactly, so pass the original value rather than a Number.
  • Date works with explicit values, such as Date.UTC(2026, 8, 21) and the UTC getters. Date.now(), new Date() without arguments, and Date() throw, because a source has no current time. Don’t pass result time values to new Date or Date.parse; see Time values. Local-time methods such as getHours use the viewer’s time zone; prefer UTC methods or Intl.DateTimeFormat with an explicit timeZone.
  • Numeric toLocaleString ignores locale and digit options here; use Intl.NumberFormat.
  • Creating an Intl formatter is slow. Create formatters in render and reuse them in cell.

There is no current time, Math.random, timer, network access, window, document or other DOM access, storage, Worker, performance, or crypto.

Text can differ slightly between browsers: Intl output depends on the browser’s locale data, and a locale the browser doesn’t support falls back to its default locale. Don’t rely on identical text for every viewer.

The same module works on a pivot table, because cell receives the original result row and field, not a screen position:

  • Value cells call cell with the result row that produced the cell and the value field, also when valuesAxis is "ROWS". Their displayText uses digits chosen across the primary and totals results, so it matches the totals beside it.
  • Row header cells call cell with the first result row of that pivot row and the row field. Their displayText is the host’s text for that value.
  • Empty cells, totals, column headers, and the Values column don’t call cell; the host shows them.
  • The result of row isn’t used, because one pivot row comes from several result rows. The host may still call it, so keep it free of side effects.
BoundaryLimit
source256 KiB in UTF-8 bytes
text4,096 UTF-16 code units, not counted when it is exactly displayText
tooltip8,192 UTF-16 code units
graphic.svg32 KiB in UTF-8 bytes, 512 nodes, 16 levels of nesting
One batchUp to 2,048 cells and 512 KiB of output; text returned as exactly displayText isn’t counted
Preparing the source10 seconds, including compiling it and running render
One batch of row and cell calls2 seconds
What failsWhat readers see
cell throws or returns an invalid cell view or graphicThat cell shows its default text with Formatting error; the message appears on hover. Other cells keep their formatting.
row throws or returns an invalid valueThat row keeps its default background, with Formatting error in its first cell.
The source doesn’t compile, render throws, or a time or output limit is exceededThe whole table shows its default display with Table formatting could not be applied. Showing the default table. and the error.

The host doesn’t retry a failed source. Fix the source in a draft; the table prepares it again when it changes.

Each example names the query it assumes, then gives the source and a TABLE widget entry with that source in module.source. Replace the query names and result keys with the ones your query returns. Add the entry to the definition’s widgets map and place it in a section with "height": {"mode": "SCALED", "scale": 1} or larger, as in Create a dashboard.

This example assumes a Semantic query named revenue_by_region grouped by the region dimension of the orders model, with TEXT column dimension.orders.region and nullable DECIMAL column metric.revenue. The source finds the lowest and highest revenue once in render, then shades each revenue cell from pale to medium blue. Its text stays exactly the host’s.

export default defineTable({
render({ queries, rowQueryName }) {
let low = Infinity;
let high = -Infinity;
for (const row of queries[rowQueryName].rows) {
const value = row["metric.revenue"];
const amount = value === null ? NaN : Number(value);
if (!Number.isFinite(amount)) continue;
low = Math.min(low, amount);
high = Math.max(high, amount);
}
return {
cell({ field, value, displayText }) {
const amount = value === null ? NaN : Number(value);
if (field !== "metric.revenue" || !Number.isFinite(amount)) return undefined;
const share = high > low ? (amount - low) / (high - low) : 1;
const alpha = Math.round(24 + share * 104).toString(16).padStart(2, "0");
return { text: displayText, background: `#3870DF${alpha}` };
}
};
}
});

The widget entry, with the same source in module.source:

{
"type": "TABLE",
"title": "Revenue by region",
"queryName": "revenue_by_region",
"columns": [
{"field": "dimension.orders.region", "header": "Region"},
{"field": "metric.revenue", "header": "Revenue"}
],
"module": {
"inputs": {"queries": [], "parameters": [], "variables": []},
"source": "export default defineTable({\n render({ queries, rowQueryName }) {\n let low = Infinity;\n let high = -Infinity;\n for (const row of queries[rowQueryName].rows) {\n const value = row[\"metric.revenue\"];\n const amount = value === null ? NaN : Number(value);\n if (!Number.isFinite(amount)) continue;\n low = Math.min(low, amount);\n high = Math.max(high, amount);\n }\n return {\n cell({ field, value, displayText }) {\n const amount = value === null ? NaN : Number(value);\n if (field !== \"metric.revenue\" || !Number.isFinite(amount)) return undefined;\n const share = high > low ? (amount - low) / (high - low) : 1;\n const alpha = Math.round(24 + share * 104).toString(16).padStart(2, \"0\");\n return { text: displayText, background: `#3870DF${alpha}` };\n }\n };\n }\n});"
}
}

A null revenue keeps its default display. When every revenue is the same, each cell gets the strongest shade.

This example assumes a query named orders_by_status with TEXT column dimension.orders.status, whose values are delivered, shipped, pending, or cancelled, and INTEGER column metric.order_count. Each known status becomes a capitalized, bold, centered label with its own colors. Other values and nulls keep the default display.

const STATUS_LABELS = new Map([
["delivered", { text: "Delivered", color: "#007044", background: "#3695651F" }],
["shipped", { text: "Shipped", color: "#3055B6", background: "#ECF4FE" }],
["pending", { text: "Pending", color: "#806A1F", background: "#806A1F1F" }],
["cancelled", { text: "Cancelled", color: "#A5092A", background: "#BE20381A" }]
]);
export default defineTable({
render() {
return {
cell({ field, value }) {
if (field !== "dimension.orders.status" || typeof value !== "string") return undefined;
const label = STATUS_LABELS.get(value);
return label ? { ...label, emphasis: "strong", align: "center" } : undefined;
}
};
}
});

The widget entry, with the same source in module.source:

{
"type": "TABLE",
"title": "Orders by status",
"queryName": "orders_by_status",
"columns": [
{"field": "dimension.orders.status", "header": "Status"},
{"field": "metric.order_count", "header": "Orders"}
],
"module": {
"inputs": {"queries": [], "parameters": [], "variables": []},
"source": "const STATUS_LABELS = new Map([\n [\"delivered\", { text: \"Delivered\", color: \"#007044\", background: \"#3695651F\" }],\n [\"shipped\", { text: \"Shipped\", color: \"#3055B6\", background: \"#ECF4FE\" }],\n [\"pending\", { text: \"Pending\", color: \"#806A1F\", background: \"#806A1F1F\" }],\n [\"cancelled\", { text: \"Cancelled\", color: \"#A5092A\", background: \"#BE20381A\" }]\n]);\n\nexport default defineTable({\n render() {\n return {\n cell({ field, value }) {\n if (field !== \"dimension.orders.status\" || typeof value !== \"string\") return undefined;\n const label = STATUS_LABELS.get(value);\n return label ? { ...label, emphasis: \"strong\", align: \"center\" } : undefined;\n }\n };\n }\n});"
}
}

The labels live in a Map, so an unexpected value such as constructor doesn’t match an inherited object property. Copy raw value still copies shipped, and sorting uses the raw values.

This example assumes two Semantic queries with the same metric, group-by, and filters over different time ranges: revenue_by_region for the current period, used as the widget’s queryName, and revenue_previous for the period before, declared in inputs.queries. Both return dimension.orders.region and metric.revenue. The source appends the change to the host’s text, such as $112.30 (▲ +12.3%) for a metric with a USD display format, colors increases green and decreases red, and shows the earlier amount in the tooltip.

export default defineTable({
render({ queries, locale }) {
const earlier = queries.revenue_previous;
const previous = new Map();
for (const row of earlier.rows) {
previous.set(row["dimension.orders.region"], row["metric.revenue"]);
}
const column = earlier.columns.find(column => column.name === "metric.revenue");
const amount = new Intl.NumberFormat(locale,
column?.numberFormat ?? { maximumFractionDigits: 20 });
const percent = new Intl.NumberFormat(locale,
{ style: "percent", maximumFractionDigits: 1, signDisplay: "exceptZero" });
return {
cell({ field, value, row, displayText }) {
if (field !== "metric.revenue" || value === null) return undefined;
const before = previous.get(row["dimension.orders.region"]);
const base = before == null ? NaN : Number(before);
if (!Number.isFinite(base) || base === 0) return undefined;
const change = (Number(value) - base) / Math.abs(base);
const arrow = change > 0 ? "▲ " : change < 0 ? "▼ " : "";
const text = `${displayText} (${arrow}${percent.format(change)})`;
const tooltip = `Previous period: ${amount.format(before)}`;
if (change === 0) return { text, tooltip };
return { text, tooltip, color: change > 0 ? "#007044" : "#BE2038" };
}
};
}
});

The widget entry, with the same source in module.source:

{
"type": "TABLE",
"title": "Revenue by region",
"queryName": "revenue_by_region",
"columns": [
{"field": "dimension.orders.region", "header": "Region"},
{"field": "metric.revenue", "header": "Revenue"}
],
"module": {
"inputs": {"queries": ["revenue_previous"], "parameters": [], "variables": []},
"source": "export default defineTable({\n render({ queries, locale }) {\n const earlier = queries.revenue_previous;\n const previous = new Map();\n for (const row of earlier.rows) {\n previous.set(row[\"dimension.orders.region\"], row[\"metric.revenue\"]);\n }\n const column = earlier.columns.find(column => column.name === \"metric.revenue\");\n const amount = new Intl.NumberFormat(locale,\n column?.numberFormat ?? { maximumFractionDigits: 20 });\n const percent = new Intl.NumberFormat(locale,\n { style: \"percent\", maximumFractionDigits: 1, signDisplay: \"exceptZero\" });\n return {\n cell({ field, value, row, displayText }) {\n if (field !== \"metric.revenue\" || value === null) return undefined;\n const before = previous.get(row[\"dimension.orders.region\"]);\n const base = before == null ? NaN : Number(before);\n if (!Number.isFinite(base) || base === 0) return undefined;\n const change = (Number(value) - base) / Math.abs(base);\n const arrow = change > 0 ? \"▲ \" : change < 0 ? \"▼ \" : \"\";\n const text = `${displayText} (${arrow}${percent.format(change)})`;\n const tooltip = `Previous period: ${amount.format(before)}`;\n if (change === 0) return { text, tooltip };\n return { text, tooltip, color: change > 0 ? \"#007044\" : \"#BE2038\" };\n }\n };\n }\n});"
}
}

A region without an earlier value, or with an earlier value of zero, keeps its default display. The change is computed with Number, which is precise enough for a percentage, while the earlier amount is formatted from its decimal string with the metric’s numberFormat.

This example assumes a query named daily_revenue grouped by day, with DATE column metric_time.day and DECIMAL column metric.revenue. The host shows a DATE as written, such as 2026-09-21. The source formats it in the viewer’s locale, such as Sep 21, 2026 in en-US or 21.09.2026 in de-DE.

export default defineTable({
render({ locale }) {
const dates = new Intl.DateTimeFormat(locale, { timeZone: "UTC", dateStyle: "medium" });
return {
cell({ field, value }) {
if (field !== "metric_time.day" || typeof value !== "string") return undefined;
const parts = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!parts) return undefined;
const day = Date.UTC(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3]));
return { text: dates.format(day) };
}
};
}
});

The widget entry, with the same source in module.source:

{
"type": "TABLE",
"title": "Daily revenue",
"queryName": "daily_revenue",
"columns": [
{"field": "metric_time.day", "header": "Day"},
{"field": "metric.revenue", "header": "Revenue"}
],
"module": {
"inputs": {"queries": [], "parameters": [], "variables": []},
"source": "export default defineTable({\n render({ locale }) {\n const dates = new Intl.DateTimeFormat(locale, { timeZone: \"UTC\", dateStyle: \"medium\" });\n return {\n cell({ field, value }) {\n if (field !== \"metric_time.day\" || typeof value !== \"string\") return undefined;\n const parts = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(value);\n if (!parts) return undefined;\n const day = Date.UTC(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3]));\n return { text: dates.format(day) };\n }\n };\n }\n});"
}
}

The source reads the year, month, and day from the string and builds the moment with Date.UTC, so formatting with timeZone: "UTC" shows exactly the result’s day to every viewer, in the Workspace’s Reporting time zone. Format hour-level DATETIME values the same way: they are already wall-clock times in that zone. Only a TIMESTAMP value from a SQL query is a UTC instant; choose the time zone readers expect for it, such as "America/New_York", and name it in the column header. Sorting the Day column still uses the raw dates.

  1. Open the dashboard’s draft with Review draft, as in Review and apply dashboard changes.
  2. Scroll through the table and look for Formatting error in cells, or Table formatting could not be applied above the table.
  3. Select a formatted cell, choose Copy selected cell, and compare Copy displayed text with Copy raw value.
  4. Sort a formatted column and confirm that the order follows the raw values.
  5. Choose Apply when the table reads correctly.
  1. Leave module out when the default display is enough; metric display formats apply without code.
  2. Read the query’s result columns and use their exact keys as field names.
  3. Return { text: displayText, ... } to change only the style, and build on displayText to add to the host’s text.
  4. Return undefined for every cell you don’t change, including nulls.
  5. Pass decimal strings to Intl.NumberFormat; use Number only to compare values or choose colors.
  6. Use frame.locale for every Intl formatter and an explicit timeZone for dates. There is no current time. Never parse DATE or DATETIME values with new Date(); read their parts and use Date.UTC.
  7. Build lookups and formatters in render, not in cell.
  8. Declare auxiliary queries in inputs.queries, never the widget’s queryName or a pivot totals query.
  9. When you return a graphic, put an equivalent description in text.
  10. After saving, review the draft in the app before choosing Apply.