The <halo-report> element
Every attribute, method and event of the web component that embeds a Halo report, with the filter values it takes and the errors it reports.
<halo-report> is a web component that draws a report in an iframe and lets your page talk to it. It adds about 2 KB gzipped, with no React and no other dependencies. For where to get an embed link and how to list your site, start with Embed a report on your site.
<script type="module" src="https://cdn.usehalo.net/halo-report.js"></script>
<halo-report src="https://{org}.usehalo.net/embed/hle_…"></halo-report><script type="module" src="https://cdn.usehalo.net/halo-report.js"></script>
<halo-report token="SIGNED_EMBED_TOKEN" origin="https://{org}.usehalo.net"></halo-report>Load the script once per page.
Attributes
| Attribute | Value | Default |
|---|---|---|
src | An embed link, https://{org}.usehalo.net/embed/hle_… | |
token | A signed embed token. Used when there's no src | |
origin | Your organization's Halo address, where a token opens | The script's own origin |
element | One element's id, drawn alone to fill the frame. A signed token's element claim wins over it | The whole report |
edition | One edition's slug, such as 2026-03, on a report with editions. For a signed token, its edition claim decides instead | The latest |
theme | light, dark or system | light |
filters | JSON: reader filters to start with, by field. See Filter values | The report's own |
state | What a filtered link carries, as its query string: page=markets®ion=North. It's what halo:filterchange reports, so you can keep it and open the same view later | The report as published |
title | The iframe's accessible name. Set it before the element connects | "Report", then the report's title |
With neither src nor token, the element shows a note saying so.
What happens when an attribute changes
| Attribute | Effect |
|---|---|
src, token, origin, element, edition | The frame reloads |
state | The frame reloads, unless it's the state the frame last reported |
theme | The mode changes without a reload |
filters | The new value is applied as with setFilters(), once the frame is ready |
title | Nothing until the frame next draws |
The frame loads lazily, so halo:ready waits until the element is near the viewport.
The address it opens
The element builds the frame's address from its attributes, and an iframe can use the same query parameters:
{src}?element={element}&edition={edition}&{state}&theme={theme}
{origin}/embed/signed?element={element}&{state}&theme={theme}#token={token}Methods
| Method | What it does |
|---|---|
setFilters(filters) | Sets reader filters by field, as a reader's pick. Before the frame is ready, they wait and go with its first message. Fields you don't name stay as they are |
getFilters() | The filters set now, by field, as the frame last reported them, a copy you can keep. Before the frame is ready, what will be sent |
reset() | Returns every filter to how the report's author left it |
The report property holds the report's id once the frame is ready.
const report = document.querySelector("halo-report");
report.addEventListener("halo:ready", () => {
report.setFilters({ region: ["North", "South"] });
});
report.addEventListener("halo:click", (event) => {
console.log(event.detail.element, event.detail.values);
});
report.addEventListener("halo:error", (event) => {
console.warn(event.detail.code, event.detail.message);
});Events
All are CustomEvents that bubble and cross the shadow boundary, with their values in detail.
| Event | detail | When |
|---|---|---|
halo:ready | { report, title, filters, edition, state } | The report has drawn and can take filters. edition is the slug of the edition showing, on a report with editions. state is the reader's view as a link's query string |
halo:filterchange | { filters, edition, state } | The filters or the page changed, by a reader or by you |
halo:click | { element, values } | A reader clicked a mark on a chart that takes clicks. values has the clicked values, by field, in the shape setFilters() takes |
halo:error | { code, message } | Something went wrong. See Errors |
halo:resize | { height } | The content's height changed, in pixels |
To have a click on one embed filter another, pass it across:
first.addEventListener("halo:click", (event) => second.setFilters(event.detail.values));Filter values
Filters are an object keyed by field name. Each lands on the report's controls over that field, as if a reader had picked it, so the report's own rules for scope, targets and dependent choices apply. null clears a field.
| Control | Value | Example |
|---|---|---|
| Single select | One value | { "region": "North" } |
| Multi-select with search | One value, or a list | { "region": ["North", "South"] } |
| Text search | Text or a number. Blank clears it | { "product": "desk" } |
| Number range | [low, high], either null for open | { "amount": [100, null] } |
| Toggle | true turns it on, and false off | { "is_member": true } |
| Date range with presets, Relative dates | { start, end } or [start, end], as YYYY-MM-DD | { "order_date": ["2026-05-01", "2026-10-31"] } |
| Date range with presets, Relative dates | A relative range | { "order_date": { "kind": "relative", "preset": "last_n_days", "n": 30 } } |
| Top N | Can't be set from outside |
Relative presets are today, yesterday, last_n_days, last_n_weeks, last_n_months, this_week, last_week, month_to_date, quarter_to_date, year_to_date, last_month, last_quarter, last_year, rolling_12_months, fiscal_quarter_to_date, fiscal_year_to_date, last_fiscal_quarter and last_fiscal_year. The last_n_* presets need n.
You can only set fields the report has a control for. To filter by something else, add a filter control to the report, or use a signed embed's locked filters.
Errors
halo:error carries a code and a message in plain words.
| Code | Means |
|---|---|
filter_not_available | The report has no control for that field, so it was left out |
filter_locked | A signed token fixes that field, so it stays as it is |
invalid_filter | The value doesn't fit the control, such as text for a date range |
link_not_found | The link was turned off or its report removed |
link_expired | The link has expired |
edition_not_found | The report has no edition by that name |
edition_withdrawn | That edition was withdrawn. The message ends with the note its author left |
locked_filter_gone | A filter fixed on the link no longer fits the report's data, so it isn't shown. Publishing the report again fixes it |
report_not_published | The report has never been published |
invalid_token | A signed token that didn't verify, or expired. Mint a new one |
element_not_found | The element isn't in the published report |
embed_domain_not_allowed | A signed embed on a site the report doesn't allow |
forbidden | The report can't be opened from here |
network | The frame couldn't reach Halo |
rate_limited | Too many requests. The report is busy |
Any other code is the public API's, passed on. A filter_* error doesn't stop the report, and the other filters in the same call still apply. A chart that asks for a field the report doesn't share says so in that chart, and your page hears nothing.
Size
The frame takes its content's height, and updates as it changes, unless your page sizes the element. Give the element a height or min-height, and the frame fills that instead:
halo-report { height: 720px; }A one-element embed asks for that element's own height. The element is display: flex and takes the hidden attribute.
Editions
On a report with editions, an embed opens the latest, with no menu to change it, unless you pin one with edition. An embed link shows the published figures. Its token decides for a signed embed. A signed embed asks your data again as of that edition's date, under your locked filters and row policies.
Themes
Embeds start light, since the page around them decides. system follows the reader's setting and updates when it changes. A report that fixes its own mode keeps it. See Your brand on reports.
How it talks to the frame
The element and the frame exchange postMessage objects marked protocol: "halo-report" and version: 1, and you don't need to send them yourself.
The element accepts messages only from its own iframe's window, at that iframe's origin, and posts only to that origin.
The frame ignores everything until a
hellofrom its parent window, fixes the parent's origin from it, then accepts only that window and origin.A plain iframe never says hello, so the frame never posts to it. That's why an iframe embed has no events, filter API or automatic height.
| Direction | Types |
|---|---|
| Element to frame | hello, setFilters, reset, theme |
| Frame to element | ready, filterchange, click, error, resize |
To keep a reader's place, store event.detail.state from halo:filterchange and set it as state next time. An iframe embed takes the same query string in its address. Unlike a public link, an embed never changes the address of the page around it.