Skip to content

Layout & containers

Layout blocks don’t draw data; they decide where everything sits. There are two layout systems:

  • @block/grid is 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/column shorthands) 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.