Markdoc widget reference#

Every markdown cell renders through Markdoc. When a query cell runs, its outputName becomes a live variable ($orders, $monthly, etc.) that markdown cells can read. Shared reports use the same pipeline.

For the notebook workflow (preview, toolbar, chart inheritance), see Results, charts, and dashboards. This page is the syntax reference.

Variables from query cells#

Only query cells with results publish variables. Markdown cells, Python cells, and cells that haven't run yet do not.

Given a query cell whose output name is orders:

Expression Value
$orders.revenue First row, revenue column
$orders.status First row, any column name from the result
$orders.rows Full result set (array of row objects)
$orders.count Row count
$orders.rowCount Same as count
$orders.columns Comma-separated column names

Column names from the first row are also available as $orders.column_name fields.

If you reference a cell that doesn't exist or hasn't been run, the editor shows a diagnostic and preview renders an error banner. Run upstream cells first.

Live numbers in prose#

Wrap values in {% ... %} so Markdoc evaluates them:

We processed {% $orders.count %} orders.
Revenue hit {% currency($orders.revenue) %} last month.
Peak month: {% formatDate($top.month, "MMM YYYY") %}.

$orders.revenue on its own in plain text is not evaluated. It is only live inside {% %} tags (prose) or tag attributes (value=$orders.revenue on a widget). The toolbar ref picker inserts $cell.column for attribute positions; wrap it yourself when writing sentences.

Format functions (currency, compact, percent, sign, formatDate) work inside those tags. Comparison functions (gt, gte, lt, lte) work inside {% if %} blocks.

Markdoc in Lunapad has no arithmetic operators and no | round pipe. If you need a ratio, percent of total, or rounded share, compute it in a query cell and reference the column:

# share
from totals
derive { pct_of_total = this_value * 100.0 / grand_total }

Then in markdown: {% percent($share.pct_of_total, 1) %}. The percent() function expects a 0–100 scale; it formats decimals and appends %, it does not multiply by 100.

Conditionals#

{% if gt($orders.count, 0) %}
  Has data.
{% else /%}
  No data yet.
{% /if %}

{% else %} must always be self-closing ({% else /%}). Chain extra branches as {% else gt($x, 0) /%}.

Editor shortcuts#

Action How
Insert $cell.col Toolbar ref picker, or type $ and pick from completions (for widget attributes)
Inline in a sentence Type {% $cell.col %} or {% currency($cell.col) %} manually
Insert a widget Type / at line start → metric, chart, callout, mermaid, …
Formatting Toolbar: bold, italic, link, heading levels
Validate Errors underline in Monaco when $ or {% is present
Preview Toggle preview on the cell

Widget tags#

Widgets use {% tagName ... /%} for self-closing tags or {% tagName %}...{% /tagName %} for blocks. Inside tag attributes, pass data with $cell.field or $cell.rows.

Format functions#

Callable inside {% ... %} prose tags and as attribute values on widgets.

Function Signature Example Output
currency currency(value) currency(42000) $42,000
compact compact(value) compact(1500000) 1.5M
percent percent(value, decimals) percent(75.25, 1) 75.3%
sign sign(value) sign(-12) -12
formatDate formatDate(value, pattern) formatDate(d, "MMM DD") Mar 05
gt / gte / lt / lte gt(a, b) - boolean, for {% if %}

formatDate pattern tokens: YYYY, MMM (3-letter month), MM (zero-padded month), DD (zero-padded day), D (day, no padding).

metric: a single KPI#

{% metric value=$orders.revenue label="Revenue" vs=$previous.revenue format="currency" /%}
Attribute Type Required Default Notes
value number or string yes - The headline number
label string no - Caption shown under the value
vs number or string no - Comparison value; when set, shows a trend arrow and percent change
format string no number One of number, currency, compact, percent

chart: any of the chart types#

{% chart type="bar" data=$orders.rows x="month" y="revenue" /%}
Attribute Type Notes
type string One of table, big-value, delta, value, line, bar, bar-horizontal, area, scatter, bubble, pie, histogram, heatmap, calendar-heatmap, funnel, box-plot, sankey, custom, sparkline
data array Rows to chart, usually $cell.rows
ref object Alternative to data: pass $cell directly to also inherit a saved chart configuration from that cell
x string Column for the X axis
y string Column for a single Y series (shorthand for yColumns=["..."])
yColumns array of strings Multiple Y series
yColumnsSecondary array of strings Y series on a secondary axis
colorColumn string Column used to color-split series or points
sizeColumn string Column used for bubble/scatter point size
seriesMode string auto, grouped, or stacked
sortOrder string none, asc, or desc, sorts by the X axis
histogramBins number Bin count, histogram only
title string Chart title
height number Pixels, default 280 (sparkline always renders at 60px regardless)

datatable: a result table#

{% datatable data=$orders.rows cols=["id","customer","total"] limit=20 /%}
Attribute Type Required Default Notes
data array yes - Rows to display
cols array of strings no all columns Which columns to show, in order
limit number no 10 Rows shown before a "view all" expansion

badge: an inline status label#

{% badge value="active" color="success" /%}
Attribute Type Required Notes
value string or number yes Text shown in the badge
color string no info, success, warning, error, or neutral. Left unset, color is derived consistently from the value text

progress: a progress bar#

{% progress value=$job.completed max=100 label="Backfill" color="success" /%}
Attribute Type Required Default Notes
value number yes - Current value, clamped to 0..max
max number no 100 Scale
label string no - Caption above the bar
color string no info info, success, warning, error

filter: a control that parameterizes upstream queries#

{% filter kind="dropdown" param="region" label="Region" options=$regions.rows optionsColumn="name" /%}
Attribute Type Required Default Notes
kind string no dropdown dropdown, text-input, date-range, button-group
param string yes - The parameter name, used as ${param} in query cells
label string no - Caption on the control
options array no - Fixed list of values, or an array of rows (use with optionsColumn)
optionsColumn string no - When options is rows, which field to use as the option value
default string no - Initial value. When set, there's no "All" choice in a dropdown

A filter doesn't wrap or filter other widgets directly. Instead, give an upstream query cell a ${param} placeholder:

from orders
filter region == "${region}"

Changing the filter control re-runs that query cell (and anything downstream of it) with the new value substituted in as raw text, so wrap it in quotes yourself for a string literal. Embedded single quotes in the value are escaped automatically.

details: a collapsible section#

{% details summary="Methodology" open=false %}
Anything can go in here: paragraphs, other widgets, lists.
{% /details %}
Attribute Type Required Default
summary string yes -
open boolean no false

tabs / tab: a tabbed group#

{% tabs %}
  {% tab label="Summary" %}
    {% metric value=$total.revenue format="currency" /%}
  {% /tab %}
  {% tab label="By region" %}
    {% chart type="bar" data=$byRegion.rows x="region" y="revenue" /%}
  {% /tab %}
{% /tabs %}

tabs only accepts tab children. Each tab requires a label and can contain anything.

columns / column: side-by-side layout#

{% columns %}
  {% column width="300px" %}
    {% metric value=$daily.revenue label="Today" format="currency" /%}
  {% /column %}
  {% column %}
    {% chart type="line" data=$trend.rows x="day" y="revenue" /%}
  {% /column %}
{% /columns %}

column takes an optional width (pixels, a percentage string, or an fr unit). Columns with no width split the row evenly.

grid: a multi-column grid#

{% grid cols=2 %}
  {% card title="Q1" %}{% metric value=$q1.revenue format="currency" /%}{% /card %}
  {% card title="Q2" %}{% metric value=$q2.revenue format="currency" /%}{% /card %}
{% /grid %}

cols (number, default 3) sets the column count. Children flow left to right, wrapping into new rows.

callout: a colored note box#

{% callout type="warning" %}
This can't be undone.
{% /callout %}

type (default info): info, success, warning, error.

card: a bordered section#

{% card title="Monthly summary" %}
  {% metric value=$monthly.revenue label="Revenue" format="currency" /%}
{% /card %}

title is optional; omit it for an untitled card.

mermaid: diagrams (all Mermaid types)#

Use {% mermaid %}...{% /mermaid %} for any Mermaid diagram type: flowchart, sequenceDiagram, stateDiagram-v2, erDiagram, gantt, pie, gitGraph, classDiagram, kanban, mindmap, timeline, and others.

Fenced ```mermaid blocks also render. They are the most reliable option for static diagrams. Only {% mermaid %} supports data-driven generation with {% group %} and {% each %} loop tags.

Static: source is preserved literally (newlines, YAML --- frontmatter, [*] state nodes, etc.):

{% mermaid %}
---
title: Order flow
---
stateDiagram-v2
    [*] --> Still
    Still --> [*]
{% /mermaid %}

Dynamic: embed {% group %} and {% each %} inside the mermaid body. The expander slices your template from the original source and interpolates bare $column tokens, preserving whitespace and indentation exactly as written. That matters for mindmap, flowchart subgraphs, and sequence diagrams. Use $keyId for Mermaid ids when the grouped value contains spaces ($keyId is slugified; $key is the display value).

Loop tag rules: do not put blank lines inside a {% each %} body (Markdoc treats them as block boundaries and breaks tag nesting). Put {% /each %} on the line immediately after the template row.

{% mermaid %}
kanban
  {% group data=$tasks.rows by="status" order=["Todo","In Progress","Review","Done"] %}
  $keyId[$key]
    {% each data=$items %}
    t$id[$title]@{ ticket: $ticket, priority: '$priority', assigned: '$assignee' }
    {% /each %}
  {% /group %}
{% /mermaid %}

Optional attribute: code=$cell.field to pass pre-built diagram text from a cell variable instead of a body.

group / each: loop tags for diagram templates#

Primarily used inside {% mermaid %}, but usable elsewhere.

{% group data=$rows by="column" order=[...] %} groups rows and exposes $key, $keyId, and $items in the body per group.

{% each data=$rows %} or {% each data=$items %} iterates rows and exposes all columns as $col in the body.

Common mistakes#

Mistake What happens Fix
$orders.revenue in plain prose Shows literally as text Use {% $orders.revenue %} or a widget tag
{{orders.revenue}} double-curly (old notebooks) Shows literally as text Rewrite to {% $orders.revenue %}
Hard-coded numbers in prose Report goes stale Reference a query cell; lint may warn
{% else %} without self-close Parse error Use {% else /%} always
percent($x, 1) on a 0–1 fraction Shows 0.5% instead of 50% Multiply to 0–100 in SQL/PRQL first
Arithmetic inside {% metric %} Not supported Compute in a query cell
Blank lines inside {% each %} in mermaid Broken tag nesting Keep the loop body contiguous
Referenced cell not run Error banner in preview Run upstream query cells
Complex static mermaid in {% mermaid %} Garbled diagram text Use a fenced ```mermaid block

Full dashboard example#

Query cells (run these first):

# orders — seed table
from orders
# monthly_revenue
from orders
derive { month = date_trunc("month", order_date) }
group month ( aggregate { total_revenue = sum amount } )
# region_performance
from orders
group region ( aggregate { total_revenue = sum amount, order_count = count this } )

Markdown cell:

## Revenue dashboard

{% badge value="Live" color="success" /%}

Month to date: **{% currency($monthly_revenue.total_revenue) %}** across {% $orders.count %} orders.

{% grid cols=2 %}
{% metric value=$monthly_revenue.total_revenue label="Revenue" format="currency" /%}
{% metric value=$region_performance.order_count label="Orders" /%}
{% /grid %}

{% chart type="bar" data=$region_performance.rows x="region" y="total_revenue" title="By region" /%}

Publish via Share or add the notebook to a Site.

Next#

AI assistant.