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.
What a table source can change
Section titled “What a table source can change”| The source decides | The host keeps |
|---|---|
| Each cell’s displayed text, text color, background, strong emphasis, alignment, and tooltip | Raw values, used for sorting and Copy raw value |
| A static SVG graphic in a cell | Rows, columns, and headers, as the definition declares them |
| Each row’s background color | Scrolling, 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.
Declare the module
Section titled “Declare the module”module has two required fields:
| Field | Contents |
|---|---|
inputs | queries, 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. |
source | The 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.
The defineTable contract
Section titled “The defineTable contract”Export exactly one defineTable call as the default export. The runtime provides defineTable; don’t import it. Its object has one method:
| Method | Contract |
|---|---|
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.
Read the frame
Section titled “Read the frame”| Field | What the source receives |
|---|---|
queries | An immutable map holding the widget’s queryName result and each inputs.queries result. Each result has columns and rows. |
parameters | Read-only values for names in inputs.parameters: the values the displayed results ran with. |
variables | Read-only values for names in inputs.variables, from the displayed results. |
locale | The viewer’s display locale, such as en-US. Pass it to Intl. |
rowQueryName | The 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
Section titled “Time values”Time values arrive as strings, already in the Workspace’s Reporting time zone:
logicalType | Where it comes from | Example |
|---|---|---|
DATE | Semantic time columns at day, week, month, quarter, or year grain, and SQL dates | "2026-08-03"; a week is its Monday |
DATETIME | Semantic 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" |
TIMESTAMP | SQL 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.
Format cells with cell
Section titled “Format cells with cell”cell receives one object:
| Field | Contents |
|---|---|
field | The cell’s result column key. |
value | The cell’s raw value. |
row | The whole result row. |
rowIndex | The row’s position in the query result, starting at 0. It isn’t the position on screen, which changes when a reader sorts. |
displayText | Exactly 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:
| Field | Contents |
|---|---|
text | Required. 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. |
color | Optional text color, #RRGGBB or #RRGGBBAA. |
background | Optional background color in the same form. |
emphasis | Optional "normal" or "strong". |
align | Optional "start", "center", or "end". Without it, numeric columns stay aligned to the end. |
tooltip | Optional, at most 8,192 UTF-16 code units. It replaces the default tooltip, which shows the raw value. |
graphic | Optional 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.
Color rows with row
Section titled “Color rows with row”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.
Add a static graphic
Section titled “Add a static graphic”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
svgelement withxmlns="http://www.w3.org/2000/svg"and aviewBox. - Elements are limited to
svg,g,defs,path,rect,circle,ellipse,line,polyline,polygon,text,tspan,linearGradient,radialGradient,stop, andclipPath. 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, andd, plusid,fill,stroke,stroke-width, the opacity attributes,transform, andclip-path.svg,g,text, andtspanalso accept text styles such asfont-family,font-size,font-weight,text-anchor, anddominant-baseline. - Colors are hex values.
fillandstrokealso acceptnoneorurl(#id)pointing to a gradient in the same image. font-familyissystem-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>Standard JavaScript in a table source
Section titled “Standard JavaScript in a table source”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:
IntlprovidesNumberFormat,DateTimeFormat,Collator,PluralRules,ListFormat,RelativeTimeFormat,Segmenter,DisplayNames, andLocalewhere the viewer’s browser supports them. Pass an explicit, non-empty locale; useframe.locale.Intl.DateTimeFormatneeds an explicittimeZone, and itsformatandformatToPartsneed an explicit date.Intl.NumberFormatformats decimal strings exactly, so pass the original value rather than aNumber.Dateworks with explicit values, such asDate.UTC(2026, 8, 21)and the UTC getters.Date.now(),new Date()without arguments, andDate()throw, because a source has no current time. Don’t pass result time values tonew DateorDate.parse; see Time values. Local-time methods such asgetHoursuse the viewer’s time zone; prefer UTC methods orIntl.DateTimeFormatwith an explicittimeZone.- Numeric
toLocaleStringignores locale and digit options here; useIntl.NumberFormat. - Creating an
Intlformatter is slow. Create formatters inrenderand reuse them incell.
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.
Format pivot tables
Section titled “Format pivot tables”The same module works on a pivot table, because cell receives the original result row and field, not a screen position:
- Value cells call
cellwith the result row that produced the cell and the value field, also whenvaluesAxisis"ROWS". TheirdisplayTextuses digits chosen across the primary and totals results, so it matches the totals beside it. - Row header cells call
cellwith the first result row of that pivot row and the row field. TheirdisplayTextis 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
rowisn’t used, because one pivot row comes from several result rows. The host may still call it, so keep it free of side effects.
Limits and errors
Section titled “Limits and errors”| Boundary | Limit |
|---|---|
source | 256 KiB in UTF-8 bytes |
text | 4,096 UTF-16 code units, not counted when it is exactly displayText |
tooltip | 8,192 UTF-16 code units |
graphic.svg | 32 KiB in UTF-8 bytes, 512 nodes, 16 levels of nesting |
| One batch | Up to 2,048 cells and 512 KiB of output; text returned as exactly displayText isn’t counted |
| Preparing the source | 10 seconds, including compiling it and running render |
One batch of row and cell calls | 2 seconds |
| What fails | What readers see |
|---|---|
cell throws or returns an invalid cell view or graphic | That cell shows its default text with Formatting error; the message appears on hover. Other cells keep their formatting. |
row throws or returns an invalid value | That 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 exceeded | The 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.
Examples
Section titled “Examples”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.
Heatmap that keeps the host’s numbers
Section titled “Heatmap that keeps the host’s numbers”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.
Status labels with colors
Section titled “Status labels with colors”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.
Change from the previous period
Section titled “Change from the previous period”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.
Readable dates
Section titled “Readable dates”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.
Check the table before you apply it
Section titled “Check the table before you apply it”- Open the dashboard’s draft with Review draft, as in Review and apply dashboard changes.
- Scroll through the table and look for Formatting error in cells, or Table formatting could not be applied above the table.
- Select a formatted cell, choose Copy selected cell, and compare Copy displayed text with Copy raw value.
- Sort a formatted column and confirm that the order follows the raw values.
- Choose Apply when the table reads correctly.
Ask for the formatting and for a draft to review. For example:
In the Analytics workspace, shade the Revenue column of the Revenue by regiontable in Regional revenue from pale to medium blue by value, keeping the numbersas they are. Leave the draft for me to review.The agent reads the query’s result columns before writing the source, and uses their exact keys as field names. The source runs in the viewer’s browser when the table is shown, so a saved definition doesn’t prove that it formats cells correctly. Review the draft in the app before choosing Apply.
Agent checklist
Section titled “Agent checklist”- Leave
moduleout when the default display is enough; metric display formats apply without code. - Read the query’s result columns and use their exact keys as
fieldnames. - Return
{ text: displayText, ... }to change only the style, and build ondisplayTextto add to the host’s text. - Return
undefinedfor every cell you don’t change, including nulls. - Pass decimal strings to
Intl.NumberFormat; useNumberonly to compare values or choose colors. - Use
frame.localefor everyIntlformatter and an explicittimeZonefor dates. There is no current time. Never parseDATEorDATETIMEvalues withnew Date(); read their parts and useDate.UTC. - Build lookups and formatters in
render, not incell. - Declare auxiliary queries in
inputs.queries, never the widget’squeryNameor a pivot totals query. - When you return a
graphic, put an equivalent description intext. - After saving, review the draft in the app before choosing Apply.