> ## Documentation Index
> Fetch the complete documentation index at: https://docs.evidence.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Html

> Build custom, interactive visualizations with HTML and JavaScript — D3, Chart.js, Observable Plot, or any JS library. A fully supported way to create bespoke charts, diagrams, and widgets the built-in components don't cover.

````liquid theme={null}
```sql daily_orders
select 'Mon' as day, 120 as orders union all
select 'Tue', 180 union all
select 'Wed', 90
```

{% html %}
<div id="bars" style="display:flex; gap:8px; align-items:flex-end; height:120px;"></div>
<script>
	// Wrap the draw in a function and register it with evidence.subscribe so
	// it re-runs whenever a filter, theme, or variable changes. evidence.query
	// always returns the latest interpolated rows, so the same function works
	// for the first render AND every subsequent reactive re-render.
	async function render() {
		const rows = await evidence.query("daily_orders");
		const max = Math.max(...rows.map((r) => r.orders));
		document.getElementById("bars").innerHTML = rows
			.map((r) => `<div style="flex:1; background:${evidence.theme.palette[0]}; height:${(r.orders / max) * 100}%"></div>`)
			.join("");
	}
	evidence.subscribe(render);
	await render();
	evidence.ready();
</script>
{% /html %}
````

## Examples

### Basic Usage

````liquid theme={null}
```sql daily_orders
select 'Mon' as day, 120 as orders union all
select 'Tue', 180 union all
select 'Wed', 90
```

{% html %}
<div id="bars" style="display:flex; gap:8px; align-items:flex-end; height:120px;"></div>
<script>
	// Wrap the draw in a function and register it with evidence.subscribe so
	// it re-runs whenever a filter, theme, or variable changes. evidence.query
	// always returns the latest interpolated rows, so the same function works
	// for the first render AND every subsequent reactive re-render.
	async function render() {
		const rows = await evidence.query("daily_orders");
		const max = Math.max(...rows.map((r) => r.orders));
		document.getElementById("bars").innerHTML = rows
			.map((r) => `<div style="flex:1; background:${evidence.theme.palette[0]}; height:${(r.orders / max) * 100}%"></div>`)
			.join("");
	}
	evidence.subscribe(render);
	await render();
	evidence.ready();
</script>
{% /html %}
````

### Responsive D3 chart from a CDN

```liquid theme={null}
{% html %}
<div id="chart"></div>
<script type="module">
	import * as d3 from "https://esm.sh/d3@7";
	const mount = document.getElementById("chart");
	// viewBox + width:100% lets the SVG scale with the container — responsive
	// with no redraw. Draw in a fixed coordinate space, then let CSS stretch it.
	const W = 400, H = 220;

	// Same pattern as the basic example: wrap the draw in a function so a
	// filter / theme change can re-run it. d3.select(...).html("") clears any
	// previous SVG before re-rendering.
	async function render() {
		const rows = await evidence.query("daily_orders");
		mount.innerHTML = "";
		const svg = d3.select(mount).append("svg")
			.attr("viewBox", `0 0 ${W} ${H}`)
			.attr("width", "100%")
			.attr("height", "auto");
		const x = d3.scaleBand().domain(rows.map((d) => d.day)).range([0, W]).padding(0.2);
		const y = d3.scaleLinear().domain([0, d3.max(rows, (d) => d.orders)]).range([H, 0]);
		svg.selectAll("rect").data(rows).join("rect")
			.attr("x", (d) => x(d.day)).attr("y", (d) => y(d.orders))
			.attr("width", x.bandwidth()).attr("height", (d) => H - y(d.orders))
			.attr("fill", evidence.theme.palette[0]);
	}
	evidence.subscribe(render);
	await render();
	evidence.ready();
</script>
{% /html %}
```

## The evidence API

Every script inside the block can reach a single `evidence` object. It is the only bridge to the page — data, variables, theme, filters, and lifecycle all hang off it.

| Member                                              | What it does                                                                                                                    |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `evidence.query(name)`                              | Rows for a named query or `sql` block declared on the page. Returns a promise, and always returns the latest interpolated rows. |
| `evidence.variables`                                | Values passed on the tag via `variables=` (see below). Always current.                                                          |
| `evidence.onVariablesChange(cb)`                    | Fires when any `variables` value changes. Returns an unsubscribe fn.                                                            |
| `evidence.theme`                                    | `{ mode, palette }` — the resolved light/dark mode and categorical color palette.                                               |
| `evidence.onThemeChange(cb)`                        | Fires when the theme or mode changes. Returns an unsubscribe fn.                                                                |
| `evidence.onResize(cb)`                             | Fires when the block's width changes. Returns an unsubscribe fn (see Sizing).                                                   |
| `evidence.subscribe(cb)`                            | Fires on any state change — variables, filters, or theme. Returns an unsubscribe fn.                                            |
| `evidence.filters`                                  | `get()`, `set(id, value)`, `create(id, value, { column })`, `subscribe(cb)` — see Parameterizing Queries.                       |
| `evidence.modal.open({ title, html })` / `.close()` | Open a full-page modal the parent renders over the report, in a nested sandbox with the same `evidence` API.                    |
| `evidence.navigate(path)`                           | Navigate to another page in the app (a drill-through). Same-origin internal paths only.                                         |
| `evidence.ready()`                                  | Signal that the first render is done. Call it after async draws so PDF/PNG export captures a finished frame.                    |

The reliable pattern is to wrap your draw in `async function render() { … }`, then `evidence.subscribe(render); await render();` — `evidence.query` returns fresh rows each call, so the same function serves the first render and every reactive re-render.

## Passing Variables

The `{% html %}` block runs verbatim in an isolated iframe: the body is never interpolated, so the way a page-level value (frontmatter, a component attribute, a repeat-scoped value, a filter value, a literal) reaches your code is the `variables={…}` attribute. Nothing crosses into the sandbox that you didn't pass. Values are evaluated on the page, snapshotted into the iframe, and read as `evidence.variables`.

```
---
selected_country: France
---

{% dropdown name=region values="north,south,east,west" defaultValue="north" /%}

{% html variables={
	greeting=$selected_country
	region="{{ region.literal }}"
	limit=10
} %}
<p id="msg"></p>
<script>
	const { greeting, region, limit } = evidence.variables;
	document.getElementById('msg').textContent =
		`Hello ${greeting}! Showing top ${limit} from the ${region} region.`;

	// React to filter / repeat-scope / frontmatter changes:
	evidence.onVariablesChange((next) => {
		document.getElementById('msg').textContent =
			`Hello ${next.greeting}! Showing top ${next.limit} from the ${next.region} region.`;
	});
	evidence.ready();
</script>
{% /html %}
```

**Inside a repeat:** pass the iteration's value through with `{{ }}`, so each iteration gets a different `evidence.variables`.

```
{% repeat id="category_repeat" data="demo.daily_orders" column="category" %}
	{% html variables={ category="{{ category_repeat }}" } %}
		<p id="c"></p>
		<script>
			document.getElementById('c').textContent = evidence.variables.category;
			evidence.ready();
		</script>
	{% /html %}
{% /repeat %}
```

**Notes:**

* **`{{ $x }}` does not interpolate inside the block** — the body is verbatim. For plain text, write it in markdown outside the block (where `{{ $x }}` works normally); inside the block, pass the value via `variables=` and read `evidence.variables.x` from a script. Validation catches both mistakes with the exact fix.
* **Reading a value you didn't pass returns `undefined`** — only `variables=` entries exist inside the sandbox. Validation flags visible reads with no matching entry.
* **Reactivity:** `const speed = evidence.variables.speed` at the top of your script captures the value once — when the attribute or filter behind it changes, your constant does not. `evidence.variables` itself is always current, so either read it where you use it (e.g. inside your render/animation loop), or register `evidence.onVariablesChange((vars) => { /* re-render */ })` for structural changes. A change that arrives while nothing is listening logs a console warning explaining this.
* Values must be serializable primitives (string / number / boolean / null). Objects, arrays, and functions are dropped before the snapshot reaches the iframe — flatten them at the call site (`start=$period.start`), or query them through `evidence.query()` instead.
* `evidence.variables` is a snapshot; mutating the returned object doesn't change anything (each read returns a fresh shallow copy).
* For *row data*, prefer `evidence.query("query_name")` over packing rows into `variables=` — query results stream lazily and aren't limited to primitives.

## Parameterizing Queries from JS

`evidence.query()` takes no parameters. To re-run a query with different inputs, create a filter in your JS and reference it from the SQL: the query re-runs server-side, and the predicate is applied on the warehouse, so only matching rows enter the iframe. Prefer this over pulling a whole table and filtering client-side.

The loop has four steps:

1. **Declare** the filter in your script: `evidence.filters.create("region", "north")` (create it before anything `.set`s it).
2. **Reference** it from any sql fence with `{{ region }}` (quoted value) or `{{ region.literal }}` (raw, for numbers).
3. **Set** it from your interaction handler: `evidence.filters.set("region", picked)` — the query re-runs on the warehouse.
4. **React**: `evidence.subscribe(render)` fires when the fresh result lands; call `evidence.query()` again inside `render` to get the new rows.

````
```sql region_sales
select category, sum(total_sales) as total
from demo.daily_orders
where region = {{ region }}
group by category
order by total desc
```

{% html %}
<select id="pick">
	<option>north</option><option>south</option><option>east</option><option>west</option>
</select>
<ul id="out"></ul>
<script>
	evidence.filters.create("region", "north");

	async function render() {
		const rows = await evidence.query("region_sales");
		document.getElementById("out").replaceChildren(
			...rows.map((r) => {
				const li = document.createElement("li");
				li.textContent = r.category + ": " + Math.round(r.total).toLocaleString();
				return li;
			})
		);
	}

	document.getElementById("pick").addEventListener("change", (e) => {
		evidence.filters.set("region", e.target.value);
	});
	evidence.subscribe(render);
	await render();
	evidence.ready();
</script>
{% /html %}
````

**When not to use this:** for high-frequency interaction over a dataset that fits in memory (scrubbing an animation slider, hover highlights), fetch once with `evidence.query()` and filter/redraw client-side — a warehouse round-trip per frame is the wrong tool. Use the filter loop when the table is too big to pull, or when other components on the page should react too (`evidence.filters.create(id, value, { column: "the_column" })` makes built-in charts with `filters="id"` follow your selection).

## Sizing and Responsiveness

By default the block **autosizes**: it grows and shrinks to fit its content height and fills the page width. Give your content a real height so it has something to size to:

* A fixed-pixel element, or a responsive SVG sized with `viewBox` + `width:100%` + `height:auto` (which takes its height from the aspect ratio).
* A `height:100%` element has nothing to fill in autosize mode. For a chart that fills a fixed box (canvas, ECharts, and Chart.js all read the container's size), pass `height=` in pixels on the tag — that pins the box and makes the mount area full-height, so `height:100%` works.

Width reflows with the page, but a chart only follows if you build for it: scale SVGs with `viewBox` + `width:100%` (no redraw needed), or redraw from `evidence.onResize(cb)` (call the library's resize method in the callback for canvas/ECharts/Chart.js). A hardcoded pixel width won't reflow.

The block is an isolated iframe, so its own width is the viewport width — CSS `@media (max-width: 480px)` queries fire at the *block's* width, which makes them behave like container queries. Use them to reflow at narrow widths (stack columns, shrink type).

Tooltips and popovers are **clipped at the block edges** — CSS `overflow` can't escape the frame. Position them relative to your own container and clamp into bounds (e.g. `left = Math.max(0, Math.min(x, mount.clientWidth - tip.offsetWidth))`), or flip them near an edge.

## Network Allowlist

Author code inside an `{% html %}` block runs in a sandboxed iframe with a content-security-policy that blocks all network traffic except to the curated hosts below. `fetch`, XHR, `d3.csv`, and `d3.json` work against these hosts; everything else is blocked at the browser level.

For data from the user's own report, always use `evidence.query("query_name")` instead — page rows live in the parent context and have no URL to fetch.

### Script CDNs

Used for loading JS libraries via `<script src>` or `import`:

* `https://cdn.jsdelivr.net`
* `https://esm.sh`
* `https://esm.run`
* `https://unpkg.com`
* `https://cdnjs.cloudflare.com`
* `https://d3js.org`

### Map tiles

Available to both `<img>` tag-based map libraries (Leaflet raster) and modern fetch/WebGL libraries (deck.gl, MapLibre):

* `https://tile.openstreetmap.org`
* `https://a.tile.openstreetmap.org`
* `https://b.tile.openstreetmap.org`
* `https://c.tile.openstreetmap.org`
* `https://a.basemaps.cartocdn.com`
* `https://b.basemaps.cartocdn.com`
* `https://c.basemaps.cartocdn.com`
* `https://d.basemaps.cartocdn.com`
* `https://tiles.stadiamaps.com`
* `https://server.arcgisonline.com`
* `https://services.arcgisonline.com`
* `https://maps.wikimedia.org`

### Images

Image-only hosts (loadable in `<img>` tags but not via `fetch`):

* `https://upload.wikimedia.org`
* `https://commons.wikimedia.org`
* `https://flagcdn.com`

### Data and public APIs

Reachable from `fetch`, XHR, `d3.csv`, `d3.json`. Includes GeoJSON / TopoJSON / Atlas files on the data CDNs (e.g. `unpkg.com/world-atlas@2/countries-110m.json`) plus keyless public-data APIs:

* `https://cdn.jsdelivr.net`
* `https://unpkg.com`
* `https://raw.githubusercontent.com`
* `https://api.frankfurter.app`
* `https://restcountries.com`
* `https://api.worldbank.org`
* `https://api.open-meteo.com`
* `https://www150.statcan.gc.ca`

### Need data from another host?

For data from your own warehouse, use `evidence.query("query_name")` — page queries aren't subject to this allowlist. To reach an external host that isn't listed, ask your Evidence admin to add a project-level allowlist entry for it.

## Attributes

<ResponseField name="width" type="number">
  Set the width of this component (in percent) relative to the page width
</ResponseField>

<ResponseField name="height" type="number">
  Set a fixed height for the chart in pixels
</ResponseField>

<ResponseField name="variables" type="object">
  Frontmatter (`$var`), filter or repeat values (`"{{ my_filter.literal }}"`), and literals to expose to the iframe as `evidence.variables`. Write `variables={ name=$frontmatter_name region="{{ region.literal }}" limit=10 }` (Markdoc object syntax: whitespace-separated `key=value`, no commas). Filter/repeat values must be quoted `{{ }}` and use a real property — `.literal` (raw) or `.selected` (quoted for SQL); there is no `.value`. Changing a value triggers `evidence.onVariablesChange(cb)` / `evidence.subscribe(cb)` inside the iframe.
</ResponseField>
