Skip to content

Visualization

Visualization blocks turn query results into something you can read. Choosing one is mostly a question of what you’re trying to show:

  • a single number (an SLO, a total, a current value) → @block/stat
  • a trend over time or any X/Y relationship → @block/plot
  • the rows themselves, with detail and drill-in → @block/table
  • part-of-whole breakdowns → @block/arc (as pie / donut / sunburst) or @block/treemap
  • relationships between entities → @block/graph
  • a trace or span timeline@block/waterfall
  • a bounded ratio like utilisation → @block/progress

Each takes its data from an expression — usually an @expr/query — and maps columns to a visual encoding by name. The exact options for each block live in the block reference; this page is about when to reach for which.

Panels

Every visualization block is a self-contained panel — it renders inside a card (heading, optional description, border) by default, so you don’t wrap it in @block/panel yourself. The panel props sit directly on the block, alongside its data props: title, description, aside, gap, width, height, padding, and chrome.

{
"@block/table": {
"from": { "query": "SELECT service, p95 FROM latency" },
"title": "Latency by service",
"description": "p95 over the selected range"
}
}

Set "chrome": false to drop the card surface — no background, no border, and no inner padding — when embedding a chart inside another block (a sparkline in a cell, a chart beside text). The heading still renders if you authored one; a block with no heading props and "chrome": false is the bare visualization.

The table’s from here is a tagged query ({ "query": … }, no @expr/ prefix), which the table composes as a sub-query — so its sort and pagination LIMIT/OFFSET push down into the database, one page per query. Give it a prefixed @expr/query instead and the query is evaluated up front and paginated in memory: cheaper paging for a modest result, but it materialises the whole result first, so prefer the tagged form for large tables.

A visualization also accepts an optional top-level id — a stable name (letters, digits, _, -, starting with a letter or _) that must be unique within the view. It’s a name the runtime can address the block by, so its state (the queries it ran, any error) can be inspected by that id rather than by position, and its errors are reported against the id instead of an opaque path. It’s separate from the card surface, so it works even with "chrome": false. Set it on the visualizations you want to reference:

{
"@block/table": {
"id": "latency_by_service",
"from": { "query": "SELECT service, p95 FROM latency" }
}
}

The id is a name, not the block’s structural identity — it doesn’t affect rendering, and changing it just re-points the name at the same (unchanged) block, without a remount. Give it a stable, meaningful value rather than deriving it from data.

For @block/plot, the mark is the block’s input, so id and the panel props live inside it (next to scales) — whichever mark it is:

{
"@block/plot": {
"line_y": { "id": "latency_over_time", "from": "spans", "y": "avg(Duration)" }
}
}

The same holds for a multi plot — they go inside multi, alongside its shared from and scales:

{
"@block/plot": {
"multi": {
"id": "latency_percentiles",
"title": "Latency",
"from": "spans",
"marks": [{ "line_y": { "y": "p50" } }, { "line_y": { "y": "p95" } }]
}
}
}

@block/arc works the same way: the shape (pie / donut / sunburst) is the block’s input, so id and the panel props live inside that tag.

Empty states

Every visualization renders a default message when its query succeeds but returns nothing to show. empty replaces that with something specific to the panel — say why there is nothing, which a generic “No data” cannot:

{
"@block/plot": {
"line_y": {
"from": "spans",
"y": "avg(Duration)",
"empty": "No traces from this service in the selected window."
}
}
}

empty also takes a block, for an empty state that acts — a link to setup docs, a button that widens the range. It is mounted only when the result is actually empty, so any queries inside it stay unrun the rest of the time. Compose a reason with a call to action in an @block/stack:

{
"@block/treemap": {
"from": "spans",
"group": "ServiceName",
"value": "count()",
"empty": {
"@block/stack": {
"direction": "column",
"items": [
{ "@block/text": "No services are reporting yet." },
{ "@block/link": { "to": "/docs/connect-data", "@block/text": "Connect a source" } }
]
}
}
}
}

Emptiness means what the chart draws: no rows for a table, no slices for an arc, no vertices for a graph, no spans for a waterfall. It is a state of the block’s own data, not of the panel — so it survives chrome: false, which is what makes it work inside a drawer.

@block/stat

A single headline number. Use it for the figures that belong at the top of a dashboard — request rate, error budget, p99 — optionally with a sparkline, and with a comparison behind it so the number carries context.

The simplest form is a value on its own:

{
"@block/stat": {
"title": "Requests",
"value": { "@expr/query": "SELECT count() FROM requests" }
}
}

Reading a column, and comparing

Give the block a from and value names a column in it rather than carrying the reading itself. That is what unlocks the comparison: when the column is a pair — what @expr/paired_query returns once a background is declared — the stat renders the foreground as the number and the background as the delta beneath it. Nothing says which column is the comparison; the column’s own shape does.

{
"@block/context": {
"extend": {
"totals": {
"@expr/paired_query": {
"from": "requests",
"select": { "Requests": "count()" },
"resolution": "none",
"background": { "timerange": { "transform": { "shift": "previous_period" } } }
}
}
},
"@block/stat": {
"title": "Requests",
"from": "totals",
"value": "Requests",
"polarity": "positive",
"delta": "relative",
"label": "vs previous period"
}
}
}
  • polarity — which direction is good: positive (up, the default), negative (down, for error rates and latencies), or neutral (no judgement). It only tints the delta; it never changes the sign.
  • deltarelative (a percentage, the default) or absolute (the signed difference in the metric’s own format).
  • label — the caption next to the delta. A pair doesn’t record what its background means, so say it here.

A column that isn’t a pair simply renders as the number, with no delta — so a page-level toggle that resolves the background away degrades to a plain stat rather than erroring.

Sparkline

Give the paired query a resolution and it also draws a sparkline beside the number — with nothing extra to author. A bucketed paired query folds each metric’s whole-window reading and its per-bucket series into the one column, so the stat takes its headline from the former and its line from the latter:

{
"@block/context": {
"extend": {
"totals": {
"@expr/paired_query": {
"from": "requests",
"select": { "Requests": "count()" },
"resolution": "low",
"background": { "timerange": { "transform": { "shift": "previous_period" } } }
}
}
},
"@block/stat": {
"title": "Requests",
"from": "totals",
"value": "Requests",
"label": "vs previous period"
}
}
}

series only tunes what’s already drawn: mark picks the shape (line, the default, area, or bar) — the background draws in the same shape, muted. A line or area background sits beneath the foreground; a bar background is grouped beside it instead — one pair of bars per bucket, background left — since two bar series at the same x would occlude each other. Author "series": false to read as a bare number instead — a KPI row where some tiles get a line and some don’t, all reading the one paired query. To draw a sparkline from a separate table (say a metric you didn’t put in the paired query), point series.from and series.value at it; series.x defaults to _ts_aligned, the aligned timestamp that overlays a shifted background without a join.

@block/plot

The general-purpose chart, built on Observable Plot. A single chart is one mark — a tagged line_y / bar_y / bar_x / area_y / cell / dot / rect / rule_y — that carries its own data (from), its channels (x, y, color, …), and its scales. Reach for a line over a @expr/timeseries_query for trends; a bar for categorical comparisons, adding fx to group bars side by side. To overlay several marks on one set of axes, wrap them in multi with a shared from/scales. Pair it with @block/crosshair for shared tooltips and brush-to-zoom.

A single line over time:

{
"@block/plot": {
"line_y": {
"from": { "@expr/query": "SELECT _ts, count FROM requests" },
"x": "_ts",
"y": "count"
}
}
}

A bar chart coloured by category, with a value-axis label:

{
"@block/plot": {
"bar_y": {
"from": { "@expr/query": "SELECT service, count FROM requests" },
"x": "service",
"y": "count",
"color": "service",
"scales": { "y": { "label": "Requests" } }
}
}
}

Bars group side by side with fx (or fy on a bar_x), a facet channel that splits the frame into one subplot per value while every other scale stays shared. Put the group on fx and the series on x: the bars inside a group abut, and the fx band spaces the groups. The colour legend already names the series, so hide the repeated inner axis with "x": { "axis": false }.

Which channel you pick decides what you get. The one on the mark’s own domain axis groups — fx for bar_y, fy for bar_x — while the other splits the chart into small multiples, where each subplot is a whole bar chart that keeps its inner axis. Setting both gives a grid of one per (fx × fy) pair. Every scale but the facet band stays shared, so the subplots are comparable however you slice them; past a handful of facets even a grouped chart reads as small multiples, since each group gets that much less width. Facet channels are bar-only.

{
"@block/plot": {
"bar_y": {
"from": { "@expr/query": "SELECT region, service, requests FROM traffic" },
"fx": "region",
"x": "service",
"y": "requests",
"color": "service",
"scales": { "x": { "axis": false }, "fx": { "label": "Region" } }
}
}
}

Several marks sharing one set of axes via multi:

{
"@block/plot": {
"multi": {
"from": { "@expr/query": "SELECT _ts, p50, p95 FROM latency" },
"marks": [
{ "line_y": { "y": "p50", "label": "p50" } },
{ "line_y": { "y": "p95", "label": "p95" } }
]
}
}
}

A cell grid coloured by a value is a heatmap. For the two common calendar shapes there’s a calendar mark that expands to a cell with the right defaults, so you supply only from, x, y, and color:

  • "interval": "week" — a GitHub-style contribution calendar: week columns (x) × weekday rows (y), one cell per day. It hides the week axis and labels the weekday rows.
  • "interval": "day" — a punch card: weekday columns (x) × hour-of-day rows (y), one cell per hour. It labels the weekday columns.

Both bin the colour into discrete swatches derived from the data (a quantile scale — no hand-picked boundaries) and fill their container by default; set aspect_ratio: 1 to square the cells instead (the chart then derives its height from its width). cell_gap is the gutter between cells in pixels — uniform on both axes whatever the cell shape — and every default is overridable via scales. The weekday axis expects an ISO day-of-week number (1 = Mon … 7 = Sun), which the mark renders as a localized name.

{
"@block/plot": {
"calendar": {
"from": { "@expr/query": "SELECT week, dow, commits FROM contributions" },
"x": "week",
"y": "dow",
"color": "commits",
"interval": "week"
}
}
}

Under the hood a calendar is a cell; reach for cell directly when you need a heatmap that isn’t a calendar (e.g. a latency × time grid). The options the calendar sets are all plain cell options: aspect_ratio: 1 squares the cells (the chart then derives its height from its width), border_radius rounds their corners, and a binned color scale gives the discrete swatches. A colour scale bins a measure three ways: "type": "quantile" (n equal-count buckets, the calendar default) and "type": "quantize" (n equal-width buckets) both derive their breakpoints from the data — like a continuous scale reading its extent — while "type": "threshold" takes explicit boundaries in domain.

Hovering a heatmap reads out the whole hovered column — one row per band with its value, swatched in that cell’s colour and with the band under the pointer emphasized — so a latency × time grid shows the distribution at that instant, not just the one cell. The measure is colour-encoded, so it has no axis to carry its format: give scales.color a format ("percent", "duration", …) and the readout uses it.

Brushing a distribution

A drag across a chart commits every axis that declares a brush: a time axis sets the page’s time range, and an axis with "brush": { "filter": { "field": … } } adds a range predicate to the surrounding FilterContext — one live filter per axis, replaced by the next drag and removable as a pill in the filter bar. So on a latency heatmap, dragging a box picks both the window and the latency band to look at.

field names the view column to filter, which is rarely the column the axis plots: a heatmap’s y is usually a query-local bucket (roundDown(DurationMs, …)) that no view scalar binds, so the predicate targets DurationMs instead. Give the axis an explicit numeric domain and its bands are read as bucket lower bounds — selecting the 100ms and 300ms bands filters DurationMs >= 100 AND DurationMs < 1000, and the top band is open-ended. Without one the bands are plain values and the range is closed at both ends. Non-numeric bands can’t express a range, so they commit nothing.

Add tables whenever field isn’t a column on every table in the view. An unscoped filter goes into each table’s WHERE, so a traces-only DurationMs against a view that also carries logs fails to compile — and against a deliberately unscoped table (disable_auto_scope) it silently narrows data that panel wants whole.

{
"@block/plot": {
"cell": {
"from": {
"@expr/timeseries_query": {
"from": "traces",
"select": [
{ "Bucket": "roundDown(DurationMs, [0, 10, 100, 1000, 10000])" },
{ "Requests": "toUInt32(count())" }
],
"group_by": "Bucket"
}
},
"y": "Bucket",
"color": "Requests",
"scales": {
"y": {
"domain": [0, 10, 100, 1000, 10000],
"reverse": true,
"format": "duration",
"brush": { "filter": { "field": "DurationMs", "tables": ["traces"] } }
}
}
}
}
}

Consistent colours across charts

By default each chart colours its categories independently, so the same value can land on different colours in two charts side by side. To pin a value to one colour across a frame, give the charts a shared share key on their colour scale. Every chart naming the same key pools the distinct values it colours by; each value is assigned a colour by its rank in the sorted union of them all — so a chart that only sees some of the values still colours the ones it has consistently with its siblings. It’s opt-in: charts without a share key keep colouring independently. share pins nothing itself, so it cannot be combined with an explicit range, domain, scheme, or { palette } — those pin the chart’s colours outright, which leaves the pooled assignment nowhere to land, so the pair is rejected rather than one silently winning. Sharing applies to categorical colours only; a chart colouring by a numeric measure keeps its continuous ramp. share sits under scales.color on every chart that has a colour scale — @block/plot, @block/arc, @block/treemap, and @block/waffle:

{
"@block/grid": {
"cols": 2,
"items": [
{
"@block/plot": {
"bar_y": {
"from": { "@expr/query": "SELECT _ts, cost, model FROM usage" },
"y": "cost",
"color": "model",
"scales": { "color": { "share": "model" } }
}
}
},
{
"@block/arc": {
"donut": {
"from": { "@expr/query": "SELECT model, cost FROM usage" },
"group": "model",
"value": "cost",
"scales": { "color": { "share": "model" } }
}
}
}
]
}
}

Sharing is frame-scoped: a key pools charts within one frame, not across frames.

Semantic palettes

When a colour channel carries a field with fixed, meaningful values — log severity, span outcome, HTTP status class — name a palette as the range instead of listing colours: "range": { "palette": "severity" }. Entries are matched to the domain by name (not position) and resolve to design-system tokens, so the colours follow the theme in light and dark. Omit domain to take the palette’s own entry order; author one only to restrict or reorder the series. The available palettes are severity, outcome, signal, http_status, and cache.

A palette matches its domain exactly — a value it doesn’t define is an authoring error, not a colour to invent, so normalize the field in SQL (lower(SeverityText), folding aliases like warning and crit onto the entry names) rather than passing the raw column. Palettes work on scales.color for every chart family: @block/plot, @block/arc, @block/treemap, and @block/waffle.

@block/table

When the rows are the answer — a list of services, the slowest traces, recent errors — show a table. Point from at a query and you get a table for free; the interesting part is configuring it:

  • Columns. Omit columns to show whatever the query returns, or list them to control order, headings, and formatting. A column can display a value ({ "title": ..., "value": ... }), a formatted value (add a format like "duration", "bytes", "percent"), or a Handlebars template that composes several fields into one cell. An optional column names the data column behind the cell — the header sorts by it and the cell’s filter menu targets it (it defaults to value; on a template or block column it is the only way to make the header interactive).
  • Drill-in. Set click to a triggerable — typically a @block/drawer — to make rows open a detail view for the row they belong to.
  • Publishing rows. A click drawer already sees the clicked row’s columns as individual context values (so {{ServiceName}} in a template and {ServiceName:String} in a query just resolve). Set as to also publish rows as whole tables the subtree can bind by name — the left side is the output, the right side the context key it lands under: "as": { "selected": "row" } binds the clicked row (a single-row table) under row, "visible" the current page, "all" the full result set. Read one field with @expr/get ("row.Duration" reads that cell), or point a table-shaped block straight at it — @block/kv from: "selected" renders the clicked row as a detail record with no re-query ("direction": "row" lays its pairs out as a wrapping header strip — label above value — instead of the default label · value column). A bare string publishes the selected row ("as": "row"); a list publishes each output under its own name ("as": ["visible", "selected"]).
  • Row links. Set link to a { "to", "label" } target to make the whole row a real navigable link instead — copy address, open in a new tab, and keyboard focus all work natively. to and label are evaluated per row with that row’s fields in scope (so a conditional target is just an @expr/case; the label is the anchor’s accessible name, not shown). link and click are mutually exclusive. A cell can still hold its own @block/link (via a block column) and stays clickable above the row link.
  • Overflow. Every table either paginates or scrolls. The default is pagination; set overflow to { "scroll": {} } to keep all rows in a scroll area instead, or to { "pagination": { "page_size": 25 } } (the page_size is bindable to a control) to size the pages. Prefer a page size from 1, 5, 10, 25, 50, 100 — familiar steps keep tables consistent across pages.
  • Content height. content_height is "fixed" by default — the body reserves a full page’s height so a short last page keeps the same footprint (no jump when paging). Set "auto" to size the body to its content instead. The panel’s own height sizes the card around it. In a notebook cell the body always sizes to its content: the result sits in the document’s flow, where reserved blank space is a hole rather than a stable slot.
  • Deferred columns. When some columns are much more expensive than the rows themselves (an aggregate over a fat fact table, a join from another signal), split them out with defer: keep from as the minimal query that defines the rows — identity, sort key, count — and declare each expensive column set as a named defer entry with its own Table-valued from and an on join key (a bare string joins same-named columns; a record maps { "table column": "defer column" }). The rows render immediately with shimmer placeholders in the deferred cells; each entry loads in the background and left-joins in when it lands. A defer query sees the table’s visible rows in scope, so it may bound itself to the displayed page ("where": "Sid IN (SELECT Sid FROM visible)") — or ignore it and load the whole window once so page flips are free. Deferred columns aren’t sortable and take no cell filter (sorting and table-local predicates compose into the from query, which doesn’t have them). Wrapping a defer entry’s timeseries in @expr/nest turns it into per-row spark cells: each row’s sub-table feeds a compact block-column plot — { "bar_y": { "compact": true, "chrome": false, "content_width": 140, "from": "series", "y": "Events" } } sizes its own box via the plot’s content_width (px), needs no height (the compact 24px minimum fits the row), and omits x (a bucketed series defaults to the aligned time column, whose axis pins to the ambient window so every row’s spark shares the same x-domain).

A rows-first table whose usage columns fill in from a second query:

{
"@block/table": {
"from": {
"@expr/query": {
"from": "logs",
"select": [{ "Sid": "SessionId" }, { "LastActive": "maxOrNull(Timestamp)" }],
"group_by": "Sid",
"order_by": { "LastActive": "desc" }
}
},
"defer": {
"usage": {
"from": {
"@expr/query": {
"from": "metrics_sum",
"select": ["SessionId", "Cost", "Tokens"],
"group_by": "SessionId"
}
},
"on": { "Sid": "SessionId" }
}
},
"columns": [
{ "title": "Last active", "value": "LastActive", "format": "relative" },
{ "title": "Cost", "value": "Cost", "format": { "currency": "USD" } }
]
}
}

A query-shaped table with formatted columns and row drill-in:

{
"@block/table": {
"from": { "@expr/query": "SELECT SpanName, Duration, StatusCode FROM traces" },
"columns": [
{ "title": "Span", "value": "SpanName" },
{ "title": "Duration", "value": "Duration", "format": "duration" }
],
"click": { "@block/drawer": { "title": "Span detail", "@block/text": "" } }
}
}

Make each row a link to that row’s frame instead of a drawer:

{
"@block/table": {
"from": { "@expr/query": "SELECT ServiceName, region FROM services" },
"link": {
"to": {
"id": "services/{ServiceName}",
"params": { "ServiceName": { "@expr/get_context": "ServiceName" } }
},
"label": { "@expr/handlebars": "View {{ServiceName}}" }
}
}
}

Switch from paging to a scroll area:

{
"@block/table": {
"from": { "@expr/query": "SELECT service, region FROM services" },
"overflow": { "scroll": {} }
}
}

@block/arc

Part-of-whole as wedges: one slice per group, sized by summed value. Reach for it to show composition — traffic by service, cost by team. The shape is an external variant, the way @block/plot tags its marks: pie (a full circle), donut (a centered hole, inner_radius defaulting to 0.6), or sunburst (an array group, nesting one ring per level for a hierarchy).

{
"@block/arc": {
"donut": {
"from": { "@expr/query": "SELECT service, count FROM requests" },
"group": "service",
"value": "count",
"inner_radius": 0.5
}
}
}

@block/treemap

Part-of-whole as nested rectangles, sized by value. Prefer it over an arc when the breakdown is hierarchical or has many categories — a treemap packs far more groups legibly than a pie.

{
"@block/treemap": {
"from": { "@expr/query": "SELECT region, service, count FROM requests" },
"group": ["region", "service"],
"value": "count"
}
}

@block/waffle

An infrastructure waffle map: one colored cell per entity (pod, host, container, CPU core), optionally partitioned into labeled sections by group — the host-map chart shape, in hex (default) or square cells. Groups form squarish multi-row clusters, contiguous by default (cell_gap: 0), on an outline-only background lattice; identity lives in the hover tooltip, not inline labels. from must produce one row per entity (duplicates throw). color drives the cell fill — numeric through a ramp, categorical through swatches, or by group when omitted — and the cell’s outline derives from it; fill takes a 0–1 share that fills the cell in its own color, from the bottom by default — { "expression": "mem_share", "origin": "center" } grows it from the centre instead, as an area-true shape. cell_gap spaces the cells. A metric with absolute meaning (CPU share, disk fullness) wants a threshold scale with authored breakpoints — threshold bins get a labeled legend. Cells sort hottest-first within each section, and limit (default 500) caps the map proportionally across groups with an honest “Showing N of M” footer.

Interactivity: hovering shows the entity’s values; group headers (and categorical legend entries) toggle filter pills; link makes every cell a real anchor to its entity page (to resolves against the cell’s row, like a table row link), while click opens a triggerable peek with the row’s fields in scope — the two are mutually exclusive.

{
"@block/waffle": {
"from": {
"@expr/query": "SELECT K8sPodName, K8sNamespaceName, avg(Value) AS CpuCores FROM metrics_gauge WHERE MetricName = 'k8s.pod.cpu.usage' GROUP BY K8sPodName, K8sNamespaceName"
},
"entity": "K8sPodName",
"group": "K8sNamespaceName",
"color": "CpuCores",
"scales": { "color": { "type": "threshold", "domain": [0.5, 1, 2], "scheme": "oranges" } },
"link": {
"to": {
"id": "@opentelemetry/k8s/{K8sNamespaceName}/{K8sPodName}",
"params": {
"K8sNamespaceName": { "@expr/get": "K8sNamespaceName" },
"K8sPodName": { "@expr/get": "K8sPodName" }
}
}
}
}
}

@block/graph

Relationships between entities. Map source / target columns to edges and the unique values become nodes — a service dependency map, a call graph. Use layout to pick the algorithm and direction. A weight column scales each edge’s stroke width (√-normalised, so hot paths read without flattening the tail). To make vertices interactive, add a nodes table — { from, id }, one row per vertex — and give it a click triggerable: clicking a vertex that has a metadata row activates it — a drawer, say — with that row’s fields in context, the same contract as a table row click. click lives inside nodes because that row is what the activation carries; a vertex the nodes table doesn’t describe has nothing to open.

{
"@block/graph": {
"from": { "@expr/query": "SELECT src_service, dst_service, calls FROM dependencies" },
"source": "src_service",
"target": "dst_service",
"weight": "calls",
"nodes": {
"from": { "@expr/query": "SELECT ServiceName FROM services" },
"id": "ServiceName",
"click": {
"@block/drawer": { "title": { "@expr/handlebars": "{{ServiceName}}" }, "@block/text": "" }
}
},
"layout": { "algorithm": "layered", "direction": "DOWN" }
}
}

@block/waterfall

The trace view: span events laid out over time and nested by parent. Map start/end timestamps, a span id, and a label; add parent_id to build the call tree. A click triggerable makes every span activatable — clicking a span’s label or bar opens it with that span’s fields in context, the same contract as a table row click; as additionally publishes the clicked span as a single-row table (selected) that a @block/kv can render whole.

{
"@block/waterfall": {
"from": {
"@expr/query": {
"from": "traces",
"select": {
"start": "Timestamp",
"end": "Timestamp + Duration",
"spanId": "SpanId",
"spanName": "SpanName",
"parentSpanId": "ParentSpanId"
}
}
},
"x1": "start",
"x2": "end",
"y": "spanId",
"label": "spanName",
"parent_id": "parentSpanId",
"as": "selected",
"click": {
"@block/drawer": {
"title": { "@expr/handlebars": "{{spanName}}" },
"@block/kv": { "from": "selected", "columns": [{ "value": "spanName" }] }
}
}
}
}

@block/progress

A bar for a bounded ratio — disk usage, error budget burndown, a step counter. Give it a fraction between 0 and 1.

{ "@block/progress": { "value": 0.72 } }