components/ folder. Each file becomes its own tag: components/kpi_card.md is called as {% kpi_card /%}. Use custom components for parameterised widgets — charts, KPI cards, tables, or any visual pattern you want to reuse with different data or labels. For verbatim snippets with no parameters (footers, disclaimers), use a partial instead.
Creating a Component
Click the+ button on the components/ folder in the sidebar and select “New Component”, or create a file at components/<name>.md. The file name becomes the tag name, so it must be a valid identifier: lowercase letters, digits, and underscores (kpi_card, not kpi-card).
A component has two parts: frontmatter declaring its attributes, and a Markdoc body that renders wherever the component is called.
Declaring Attributes
Every attribute is declared underattributes: with a type: field — there is exactly one declaration syntax:
string, number, boolean, column, query, format, sql, date_range, comparison, filter. Types drive editor behavior at the call site — a query attribute gets table autocomplete, a column attribute gets column autocomplete, and so on.
A common mistake is writing value: total intending “default is total” — that position declares the type. The editor flags it on the exact line with the fix.
Using Attributes in the Body
Attribute values are available as$name variables inside the body:
- In tag attributes: unquoted
data=$data, or interpolated inside stringstitle="{{$title}} (YTD)" - In text:
## {{ $title }} - In SQL:
select {{ $value }} as v from ...
date_range attribute named period provides {{ $period.start }} and {{ $period.end }}.
Attributes can also carry runtime values: pass a page input’s value at the call site — {% cat_explorer category="{{ region.value }}" /%} — and it stays live everywhere the attribute is used. The template flows through: in the component’s SQL it becomes a real filter reference (the query re-runs when the input changes), and in text or tag attributes it updates reactively, exactly as if written on the page directly.
Calling the component validates against the schema: missing required attributes and unknown attribute names error at the call site while you type, and a typo like {{ $titel }} inside the body errors when editing the component.
Previewing While You Build
While a component file is open, its preview renders standalone — but attributes get their real values from call sites, so an attribute the SQL consumes needs an authoring fixture to preview against. Two ways to provide one:default:on the attribute — also applies at call sites that omit the attribute.preview:block — used ONLY when editing this file, never at call sites. This is the right home for required attributes with no sensible production default:
preview: as the component’s built-in test fixture: with real values set, the standalone preview validates your SQL against the actual schema.
Where a Component’s Data Comes From
Two patterns — pick by whether the data varies per use: Data varies per call site (a reusable widget over arbitrary sources): declare aquery-type attribute and let each caller pass its source.
{{ other_query }}. If a private query errors, the message names it as <component>:<query> (e.g. kpi_card:revenue) so you know where to look. The : character is reserved for this — you can’t use it in your own query names.
Common Patterns
Accept data from the caller
Declare aquery attribute; each call site points it at a table or SQL file. Give it a preview: (or default:) so the file previews standalone.
Own your data
Embed the SQL when the component always computes the same thing — it stays private to the component (see “Where a Component’s Data Comes From”).React to a page input — two ways
Decoupled (recommended): the caller passes the input’s value; the component never needs to know the input exists. Works with any input on any page..selected is the SQL-quoted form ('Clothing') — right inside a query, wrong in a heading. For display text use the bare form (## {{ region }}) or {{ region.literal }} (unquoted); validation hints if you mix them up.
Named coupling: reference the page input directly by id inside the component’s SQL (where region = {{ region.selected }}). Simpler, but the component now requires every page to have an input with that id — and validation enforces it: a page using the component without the input gets an error at the call site naming the filter and the fixes. (The component file itself stays quiet about it — the file alone can’t know what pages provide.)
Ship your own input
An input declared in the component body registers on the page like any input, and the component’s own SQL can reference it — a fully self-contained filter panel:{{ region.selected }} too, and two copies of this component on one page share the ONE filter: selecting in either card updates both. Sometimes that’s what you want (one control, many views). If instances should filter independently, declare the id as an attribute and pass a different value per call site:
Custom visualizations and JS-created filters
{% html %} blocks work inside components — pull the component’s own queries with evidence.query("name"), receive attribute values via variables={ x=$x }, and create page filters from JS with evidence.filters.create(id, initial). See the html block docs for the full SDK.
Inputs and Filters Inside Components
A component body can contain inputs ({% dropdown %}, {% range_calendar %}, etc.). These behave exactly as if they were written on the page: the input registers page-wide under its id, syncs to the URL, and the page (or other components) can reference {{ my_input.value }}. This works at any nesting depth.
Because the id is page-global, dropping the same input-containing component onto a page twice makes both instances share one filter. For a component meant to appear multiple times per page, accept the id as an attribute instead.
Composition and Naming
- Components can use other custom components, built-in tags, and
{% html %}blocks in their bodies. An{% html %}body is NOT interpolated — to use an attribute inside one, pass it on the tag ({% html variables={ title=$title } %}) and readevidence.variables.titlefrom a script. For plain text, prefer markdown outside the block, where{{ $title }}interpolates normally. Validation hints teach both if you forget. - Components work inside layout tags like
{% row %}. One layout caveat: a component bundling a full-width input or an autosized{% html %}block won’t collapse into narrow row columns as neatly as a bare chart — for tight grids, keep inputs outside the component or give the html block a fixedheight=. - A component cannot use its own tag — recursion stops after one level, so self-reference is a validation error.
- Tag names are project-wide and flat:
components/charts/bar.mdandcomponents/maps/bar.mdboth collapse to{% bar %}and collide — both files get an error. A component named after a built-in tag (value,note,filter_bar, …) is dropped in favor of the built-in, with an error on the file.
Slots: Wrapping Page Content
Render{% slot /%} anywhere in a component’s body and the component becomes a container — everything the caller writes between the opening and closing tags renders at the slot position. No frontmatter flag is needed; the slot’s presence is what makes the tag accept children.
{% html %} blocks. They are evaluated in the caller’s scope: their queries and inputs are the page’s, and $variables in them resolve against the page, not the component. A self-closing call ({% warning_box /%}) renders the slot’s fallback content — whatever you write between {% slot %} and {% /slot %} — or nothing.
Named slots
For multi-region layouts, name the slots and fill them with{% fill %}:
{% fill %} flows into the unnamed default slot (if the body has one). An unfilled named slot keeps its fallback. Mistakes fail loudly at author time: children passed to a component with no slot error with the fix, and a {% fill %} naming a nonexistent slot errors listing the slots that do exist.
Current Limitations
- Scalar attribute values: attributes accept strings/numbers/booleans (plus the typed editor behaviors above) — not arrays or objects. For list-shaped props (a set of series or columns), pass a comma-separated string and split it in the body — e.g.
series="revenue,cost,margin"with SQL that filters on the parts, or hand it to an{% html %}block viavariables=andString(evidence.variables.series).split(',').

