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

# Html

> Build fully custom, interactive visualizations with HTML and JavaScript — including D3, Chart.js, Observable Plot, or any JS charting/graphics library. This is a first-class way to create bespoke charts, diagrams, and widgets the built-in components don't cover; reach for it whenever someone asks for D3 or a custom/complex visualization, and honor the specific library they name. The tag body is HTML; any `<script>` runs in a secure isolated sandbox. Data is PULL-based (there is no `data=` attribute): fetch a named query's rows with `await evidence.query("query_name")`, where the query is declared elsewhere on the page as a SQL file or inline ```sql block — multiple queries can be used in one block. To pass page-level values (frontmatter, repeat scope, filter results, literals) into the iframe, use a `variables={…}` attribute on the tag — `{% html variables={ name=$customer region=region_filter.value limit=10 } %}` (Markdoc object syntax: whitespace-separated `key=value`, no commas) — and read them inside the block as `evidence.variables.name`, etc. Reactive: when a value changes (e.g. the user changes a filter), `evidence.onVariablesChange(cb)` and `evidence.subscribe(cb)` fire so the author can re-render. The author-facing API lives under a single `evidence` namespace: `evidence.query(name)` → rows; `evidence.variables` → values passed via the `variables=` attribute; `evidence.onVariablesChange(cb)`; `evidence.theme` → { mode, palette }; `evidence.onThemeChange(cb)`; `evidence.subscribe(cb)` → re-render on any filter/theme/variable change; `evidence.filters.get()/.set(id, value)/.subscribe(cb)` for cross-filtering, and `evidence.filters.create(id, initialValue)` to declare your OWN page filter (e.g. a custom dropdown) that you reference from any ```sql query with `{{ id }}`/`{{ id.selected }}` (quoted) or `{{ id.literal }}` (raw), exactly like a built-in input — `.create` it first, then `.set` it on interaction (`.set` alone only works on a filter that already exists); pass `evidence.filters.create(id, initialValue, { column: "the_column" })` to bind it to a column so it auto-applies to BUILT-IN charts via their `filters="id"` prop (it emits a real `column = value`/`IN (…)` predicate, just like a built-in dropdown); `evidence.ready()` to signal the first render is complete (call it after async draws so PDF/PNG export captures a finished frame). Load libraries from one of the allowlisted CDNs (esm.sh, esm.run, cdn.jsdelivr.net, unpkg.com, cdnjs.cloudflare.com, d3js.org). For ES modules use a module script: `<script type="module">import * as d3 from "https://esm.sh/d3@7"</script>` (a bare `import` in a classic `<script>` is a syntax error). Classic inline `<script>` supports top-level `await`. For data from the user's page, ALWAYS use `evidence.query("query_name")` — page rows live in the parent and have no public URL to fetch. External public data is reachable: `fetch`, XHR, `d3.csv`, and `d3.json` work against a curated network allowlist that covers common analytical hosts (map tile servers, data CDNs, keyless public-data APIs like World Bank, Open-Meteo, REST Countries, Frankfurter currency, and Statistics Canada). See the "Network Allowlist" section of the html docs page for the full, current list. Other hosts are blocked — if blocked, switch to a query against a connected source, or surface the host to the user so they can request a project-level allowlist. Put render logic inside `<script>` and, if it should react to filter/theme changes, wrap it in `evidence.subscribe(render)`. By default the block autosizes to its content height (and fills the page width), growing and shrinking as the content does — so give your content a real height: a fixed-pixel element, or a responsive SVG sized with `viewBox` + `width:100%` + `height:auto` (which gets its height from the aspect ratio). A `height:100%` element has nothing to fill in autosize mode, so for charts that fill a fixed box (canvas/ECharts/Chart.js read the container size) pass `height=` (pixels) — that pins the box AND makes the mount area full-height so `height:100%` works. The block resizes its width with the page, but a chart only reacts if you make it responsive: scale SVGs with `viewBox` + `width:100%` (no redraw needed), or redraw from `evidence.onResize(cb)` (call the library's resize in the callback for canvas/ECharts/Chart.js). Never hardcode a pixel width and expect it to reflow. Tooltips/popovers are clipped at the block edges (it's an isolated frame — CSS `overflow` can't escape it), so position them relative to your container and clamp into bounds — `left = Math.max(0, Math.min(x, mount.clientWidth - tip.offsetWidth))`, same for `top` — or flip near an edge, so they never get cut off.

````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 %}
```

## Passing Variables

The `{% html %}` block runs in an isolated iframe, so the only way to get page-level values (frontmatter, repeat-scoped values, filter values, literals) into your author code is through the `variables={…}` attribute. The values are evaluated on the page, snapshotted into the iframe, and exposed as `evidence.variables`.

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

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

{% html variables={
	greeting=$selected_country
	region=region.value
	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:** the loop variable resolves at the call site, so each iteration gets a different `evidence.variables`.

```
{% repeat for=country in=country_list %}
	{% html variables={ name=country.name population=country.population } %}
		<p>{evidence.variables.name} — pop. {evidence.variables.population}</p>
	{% /html %}
{% /repeat %}
```

**Notes:**

* 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, 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.

## 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`

### What's NOT on the list

* Private corporate APIs (e.g. your internal data services)
* Key-required public APIs (OpenWeatherMap, FRED, NASA, exchangerate.host) — keys handed to iframe code leak the moment the report is shared
* Arbitrary third-party endpoints

If your visualization needs a host that isn't on the list, ask your Evidence admin to add a project-level allowlist entry for it (a per-project allowlist surface is in the works). For broadly-useful keyless public hosts, [open an issue](https://github.com/evidence-dev/studio/issues) and we'll consider adding it to the default.

## 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, repeat-scoped, or literal values to expose to the iframe as `evidence.variables`. Mirrors the `variables=` attribute on `{% partial %}` — write `variables={ name=$frontmatter_name label="static" }` (Markdoc object syntax: whitespace-separated `key=value`, no commas). Reactive: changing a value triggers `evidence.onVariablesChange(cb)` / `evidence.subscribe(cb)` inside the iframe.
</ResponseField>
