Layout & containers
Layout blocks don’t draw data; they decide where everything sits. There are two layout systems:
@block/gridis a fixed, pixel-height grid. Use it for the top-level arrangement of a dashboard, where charts and panels hold a stable position and don’t reflow as data loads.@block/stack(and its@block/row/@block/columnshorthands) is flow layout — it sizes to its content and flows. Use it inside a grid cell, to line up a handful of controls, or to stack a label above a value.
The single-child containers (@block/box, @block/panel) and the flex containers (@block/stack / row / column) all share the same sizing-and-spacing vocabulary from @block/box — width, height, and padding — so once you can size a box you can size any of them. (Grid is the exception: it’s a fixed-height system with its own cols / row_height.) Children go in an items array; the single-child containers take their child inline or under block. For the full prop list, see the block reference.
@block/box
The layout primitive: one child, with sizing (width, height) and inner padding. Reach for it when you just need to pad something, cap a width, or make a child fill its parent. Everything else here builds on these same props.
{ "@block/box": { "padding": "md", "@block/text": "padded content" } }@block/stack, @block/row, @block/column
Flow layout in one direction. @block/stack takes a direction; @block/row and @block/column are the same thing with the direction baked in (and they accept a bare array of children). Beyond the box props they add gap between children, align_items ('start' | 'center' | 'end') for cross-axis alignment, and justify_content ('start' | 'center' | 'end' | 'space-between' | 'space-around' | 'space-evenly') for main-axis distribution — e.g. justify_content: "end" on a @block/row pushes a control to the far edge.
Set divider: true to draw a rule between adjacent children, in the middle of the gap on either side of it: a horizontal rule in a column, a vertical rule in a row. A child that renders nothing, such as a @block/case branch that resolves to a @block/fragment, gets no rule.
Use it for the small pieces: a strip of controls, a stat with a label above it, the contents of one grid cell.
{ "@block/stack": { "direction": "row", "gap": "md", "align_items": "center", "items": [{ "@block/text": "left" }, { "@block/text": "right" }] }}{ "@block/row": [{ "@block/text": "left" }, { "@block/text": "right" }] }Every one of these — width, height, padding, gap, align_items, justify_content, divider — takes an expression, so a container reshapes itself instead of being duplicated per shape: a drawer that widens for a detail view, a strip that stacks tight when a density control says compact.
{ "@block/stack": { "direction": "row", "gap": { "@expr/case": { "density": { "compact": "xs", "default": "md" } } }, "align_items": { "@expr/get_context": "align" }, "items": [{ "@block/text": "left" }, { "@block/text": "right" }] }}@block/grid
The top-level dashboard layout — a fixed-height grid on a column track (12 columns by default). Items declare their cols / rows span and optional x / y position; every row is a fixed pixel height (row_height), so the layout doesn’t jump as panels load. Reach for grid at the top of a data-heavy page and drop a chart, table, or panel into each cell.
{ "@block/grid": { "cols": 12, "items": [ { "cols": 12, "rows": 1, "@block/query_bar": {} }, { "cols": 6, "rows": 4, "@block/plot": { "line_y": { "from": "requests", "y": "p95", "title": "Latency" } } }, { "cols": 6, "rows": 4, "@block/plot": { "line_y": { "from": "requests", "y": "errors", "title": "Errors" } } } ] }}The grid is responsive, and spans are proportional. The column count follows the width of the grid’s own container rather than the viewport, stepping 1 → 2 → 4 → your authored cols as it widens, and never exceeding what you authored (a cols: 2 grid tops out at 2). An item’s span scales with it: cols: 3 of 12 is a quarter of the row, so it stays a quarter at every step rather than spanning past the columns a narrow container has. Author the span you want when there’s room and the rest follows.
Put a panel in a grid when its height doesn’t depend on its data. Charts, stats and treemaps fill whatever cell they’re given, and a paginated table has a known page height — those belong on the grid, where every row is a fixed height and the page stops reflowing as panels load. Keep a panel content-sized when its height is the data: a header strip, or a short inventory whose row count varies (author content_height: "auto" on the table and leave it in a @block/column). Sizing a cell to a table means matching it to the page — too small hides rows behind an inner scrollbar, too large leaves dead space in the card.
Set "collapse": false when the grid’s shape matters more than its contents — a matrix of compact sparklines, a keypad. Such a grid renders its cols columns at every width instead of stacking into one column when its container is small — the right behavior for a dense block sitting inside a narrow panel.
x / y pin an item to a specific track, so they only apply where the full authored column track exists — anywhere narrower the item auto-places. Prefer authoring order to pins: items placed in reading order reproduce most layouts on their own and degrade correctly, whereas a pinned layout silently loses its arrangement below the widest step.
@block/panel
A titled card — a heading, an optional description, and a border that separates its body from its neighbours.
Every visualization block is already a panel: it renders inside a card by default and takes the panel props (title, description, chrome, …) directly, so you rarely wrap a chart or table in @block/panel yourself (see Panels). Reach for @block/panel directly when the body is not a single visualization — wrapping a @block/stack, some text, or several blocks in one card:
{ "@block/panel": { "title": "Settings", "description": "Manage settings", "@block/text": "Body content" }}Like a visualization block, @block/panel accepts an optional top-level id — a stable, view-unique name the runtime can address and inspect it by.
@block/tabs and @block/accordion
Progressive disclosure for when there’s more than fits on one screen. Use tabs to show one view at a time behind a tab bar (overview / details / logs); use an accordion to stack collapsible sections the reader can open independently.
@block/tabs takes one arm, tabs, holding items — a map of tab id to { title, <child> }, where the child is an inline @block/* key or an explicit block.
{ "@block/tabs": { "tabs": { "items": { "overview": { "title": "Overview", "@block/text": "Overview content" }, "details": { "title": "Details", "@block/text": "Details content" } } } }}selected binds which tab is current, and holds the id. Omit it and the selection is local state starting on the first tab; give it @expr/url_state to put the tab in the URL so a link can land on it, or @expr/context_state to share it with the rest of the page.
{ "@block/tabs": { "tabs": { "selected": { "@expr/url_state": { "key": "section", "schema": { "type": "string" }, "defaults": "overview" } }, "items": { "overview": { "title": "Overview", "@block/text": "Overview content" }, "details": { "title": "Details", "@block/text": "Details content" } } } }}A second arm, links, makes the strip navigate instead of toggling in place. Each item is a frame target in @block/link’s grammar — frame or frame_instance, carrying the tab’s title — and the required child is the slot underneath. Every frame in the set carries the same strip, and whichever one the reader is on is the one that marks; there is no selected, because the frame being rendered already settles it.
{ "@block/tabs": { "links": { "items": [ { "frame": { "id": "@opentelemetry/hosts/{HostName}", "title": "Overview" } }, { "frame": { "id": "@opentelemetry/hosts/{HostName}/cpu", "title": "CPU" } } ], "@block/use": "@frames/@opentelemetry/hosts/{HostName}/tabs/cpu.templates.json#/block/body" } }}A to (URL) arm is deliberately not offered here: a strip picks its current trigger by comparing targets against the route being rendered, and a URL names no route to compare. The deepest target containing the live route wins, so a strip entry stays marked while the reader is on a route nested below it, and only the most specific one marks.
A third arm, children, is the strip over a page’s sections. Each entry names a key in the page’s routes.children, which supplies the trigger’s title and its URL, and the strip’s child is the @block/outlet those sections render into — so the whole page is this one block.
{ "@block/tabs": { "children": ["overview", "traces"] } }Reach for links instead when the sections belong to different pages, or when the outlet has to sit somewhere the strip is not.
@block/accordion takes items as a map of id to { title, <child> }, and multiple (default true) decides whether more than one section can be open at once.
{ "@block/accordion": { "items": { "overview": { "title": "Overview", "@block/text": "Overview content" }, "details": { "title": "Details", "@block/text": "Details content" } } }}@block/sidebar
A collapsible side panel — a nav rail, a filter column, a legend you can tuck away. It holds one child (inline or under block), starts collapsed by default, and keeps its body mounted while collapsed so reopening it doesn’t re-run queries. open binds the collapsed state (give it an @expr/state seeded true to start open); title labels the panel and icon (Lucide, default square-arrow-right) sets the toggle. It also takes the box sizing props (width, height, padding).
{ "@block/sidebar": { "title": "Filters", "@block/facet": { "field": "ServiceName" } }}@block/layers
Stacks children on top of each other instead of next to each other. The first child sets the size and sits at the back; each later child is stretched over it, in array order. Reach for it to overlay an annotation, a badge, or a control on a chart.
{ "@block/layers": [ { "@block/plot": { "line_y": { "from": "requests", "y": "count()" } } }, { "@block/badge": { "title": "Live", "color": "success" } } ]}@block/drawer
Pushes detail off to the side without leaving the page — a slide-out panel opened from a @block/button or a @block/table row (see their click). Its open/closed state is a bound boolean; the authored form pairs that state with the panel’s content.
{ "@block/drawer": [{ "@expr/state": false }, { "title": "Details", "@block/text": "Drawer body" }]}@block/fragment
Returns several blocks where one is expected, without adding a wrapper element to the layout. Handy when a template or a branch needs to emit a list of siblings into its parent.
{ "@block/fragment": [{ "@block/text": "First" }, { "@block/text": "Second" }] }An id alongside a single child names that subtree with a declared node id without adding anything to the layout — for a child block that has no id prop of its own:
{ "@block/fragment": { "id": "recent_activity", "@block/column": [{ "@block/text": "Recent" }, { "@block/table": { "from": "activity" } }] }}Declared ids must be unique wherever the nodes can mount together, so a shared template used more than once must not declare a literal id itself. To name a template instance, use @block/use’s own id rather than wrapping it here.