Inputs & controls
Controls are what make a frame interactive. There are two kinds, and they differ in where their value goes:
- Filter controls —
@block/filter_bar,@block/search,@block/field_filter,@block/facet,@block/query_bar— push a filter into the surrounding@block/filter_context, which descendant queries automatically respect. You wire nothing; drop a filter context around a table and a filter bar above it, and adding a filter filters the table. - State controls —
@block/switch,@block/checkbox,@block/option,@block/timerange— read and write a shared state key. Bind the control and a query to the same key (@expr/context_state/@expr/state) and the page reacts as the reader changes it.
@block/button triggers an action, @block/pagination / @block/cycle step through data, and @block/crosshair coordinates chart interaction. Full options are in the block reference.
@block/timerange and @block/query_bar
The time picker. On its own, @block/timerange drives the page’s shared _timerange, which every context-bound query already follows — so adding it makes the whole page time-aware. @block/query_bar is the common header combo: a filter bar and a time picker in one row — the default page header. It forwards filters and placeholder to the filter bar.
{ "@block/query_bar": {} }@block/filter_bar
The filter surface. It displays the active filters from the surrounding filter context as pills — each with a funnel toggle (temporarily excludes the filter from queries without removing it), a clickable label that reopens the predicate editor to edit the filter in place, and a remove button — plus a plus button that expands an in-place predicate input with autocomplete.
Edits commit via the Done button or Enter. Each predicate is first validated with a cheap database round-trip; one that fails (bad syntax, unknown column, type error) can’t be committed, and the database’s error is shown inline. Filters are combined with AND.
Predefine filters with filters; each takes an expression (a boolean SQL predicate), an optional label, and an optional disabled to start paused. size picks the control tier: sm (default, matches the time picker) or xs (dense). On a multi-table view, set table to scope every filter the bar owns (and its autocomplete and validation) to one table; omit it to operate across all view tables — autocomplete unions their columns, and committed filters apply to every table.
{ "@block/filter_bar": { "filters": [{ "expression": "StatusCode = 'Error'", "label": "Errors only" }] }}Filters can also be added straight from the data, without touching the bar. Hovering a @block/table cell reveals a menu button beside its value that filters to or excludes that cell’s value; applying the same action again takes it back off (the menu reads “Remove filter”). When the column is a view column (a with: scalar or a physical column of the view tables), this pushes a frame-wide filter — an ordinary filter-context pill that surfaces in a @block/filter_bar if one is present and re-queries the whole frame. Any other filterable column — most usefully a grouped table’s aggregate alias, which can’t fold frame-wide — filters that table locally instead, shown as a removable chip above its body. A categorical @block/plot legend item opens the same quick menu on click, alongside its “only show this” / “hide this series” toggles, when its coloured series resolves to a view column.
By default a @block/table column is filterable when it’s string-typed. A column’s filter prop overrides that: false opts out, true opts in regardless of type, and a string is a table-local predicate the cell value is injected into. A bare column name ("WorkspaceName") is shorthand for "WorkspaceName = ?"; ? binds the clicked cell’s value (positional) and {Column} binds that row’s value for another column (named), so "lower(name) = lower(?)" and "Region = {Region} AND Cost > ?" both work. Exclude negates the whole predicate.
@block/search, @block/field_filter, and @block/facet
Filtering controls. @block/search commits free text as a filter; @block/field_filter autocompletes a column’s values in a combobox and filters on the selection; @block/facet is the labeled sidebar variant — it lists a column’s values inline as a checkbox group, with a display limit and searchable: true to add a search input above the list for high-cardinality fields. That input’s placeholder belongs to the input, so it is authored inside searchable: "searchable": { "placeholder": "Find a service…" }. All write into the nearest @block/filter_context, so the recipe is: a filter context wrapping the data, with these controls inside it. A bar without a table suggests only the columns EVERY view table has, since its predicate folds into all of them — if that comes up empty, the view combines tables with nothing in common and the odd one out belongs behind its own @block/frame.
Scope a control to the view table that defines the column with table, so a multi-table view doesn’t inject the column into a query whose table lacks it. Omit table and the control spans every view table — @block/search then autocompletes the union of their columns (ones present in every table bubble to the top) and validates a committed predicate against each, since the filter folds into all of them.
{ "@block/field_filter": { "field": "StatusCode", "placeholder": "Filter by status…" } }{ "@block/facet": { "field": "Model", "table": "metrics_sum" } }@block/switch, @block/checkbox, @block/option
The bound-state controls — a segmented control, a checkbox, and a single/multi select. Reach for these when the reader’s choice should change a query or another block: bind the control’s value and the query to the same key.
@block/switch is a segmented control with one segment per case — the interactive mirror of @block/case. Its single top-level key is a shared state slot (declared in a surrounding @block/context); the case map turns each value into a labelled segment, and selecting one writes that value to the slot. A @block/case or a query bound to the same slot then reacts.
{ "@block/switch": { "status": { "active": "Active", "inactive": "Inactive" } }}Prefer @block/switch whenever the choice is a handful of peer values — roughly three or fewer — so every option stays visible and one click away.
@block/option is the single/multi select; reach for its dropdown when a segmented control is the wrong shape: many options, options resolved from data, multi-select, or a choice where one entry is an absence rather than a peer value. A placeholder plus a “None” entry expresses “not set” in a way a segmented control can’t. The top-level key picks the cardinality — single or multiple — and carries all the props; selected is a string binding under single and a string-list binding under multiple.
{ "@block/option": { "single": { "placeholder": "Break down by", "options": [{ "value": "None" }, { "value": "By host" }, { "value": "By region" }], "selected": { "@expr/context_state": "breakdown" } } }}Each item requires value (what selected stores); title and icon (Lucide, kebab-case) are optional display fields — the label falls back to value.
{ "value": "p95", "title": "95th percentile", "icon": "chart-line" }options resolves eagerly by default, so the closed trigger always shows the selection’s title and icon. Set lazy: true for a query-backed source that should only run while the dropdown is open — the block passes the typed search text into the expression’s scope as _search (read it with { "@expr/get_context": "_search" }), and the last resolved list is kept after the dropdown closes.
Behaviour props, all off by default: searchable turns the trigger into a filter input (true, or { "placeholder": … } to also set the search field’s own placeholder; an eager list filters as you type, a lazy one re-queries through _search), clearable makes the empty selection a legal state with a clear affordance (lazy forces it on; the object form { "empty_label": "All services" } names what emptiness means, shown as a value while nothing is selected), and display: "icon_only" collapses the trigger to the selected item’s icon — or a chevrons-up-down fallback — with placeholder as its accessible label.
@block/button
Triggers an action on click — most often opening a @block/drawer or another triggerable. Use it for “view details”, “open settings”, anything that reveals more without navigating away.
{ "@block/button": { "title": "Open", "click": { "@block/drawer": { "title": "Details", "@block/text": "Drawer body" } } }}@block/pagination and @block/cycle
Step through a dataset a slice at a time. @block/pagination writes the current page into a binding (drive a list or a @block/for from it); @block/cycle is the headless version that advances through items — useful for a rotating view. Both write the current slice/item into a state key you then read elsewhere.
{ "@block/pagination": { "data": { "@expr/query": "SELECT * FROM events" }, "displayed": { "@expr/context_state": "displayed" }, "page_size": 25 }}@block/crosshair
Wraps a group of charts so they share a hover crosshair and tooltip, and lets the reader brush a time axis to set the page’s time range. Its enabled and top_n govern every chart’s hover readout in scope, categorical bars included — those need no shared cursor, so they read out on their own, but "enabled": false silences them too. Wrap your plots in one to make a row of charts feel like a single coordinated view. A drag commits every brushable axis at once, so a chart whose value axis declares a filter brush picks a range on both.
{ "@block/crosshair": { "top_n": 5, "block": { "@block/text": "Charts" } } }