Skip to content

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" } } }