Layout & responsive design
Layout is flex-based, not pixel-nailed. You group widgets in containers, decide how each child grows and aligns, and let the runtime reflow. Then you branch the layout by screen size so one project serves phone, tablet and panel PC.
Build layout with containers
- Add a Container — Drop a Container where you want a group. Set its Direction — row, column or stack — plus Gap and Align.
- Nest your widgets — Move widgets into the container in the tree. They flow in order along the direction you chose.
- Tune each child — Select a child and set its Width and Height to Hug, Fill or Fixed. This is how you get "this panel fixed, that one fills the rest".
Use a Stretch Spacer to push widgets apart (by ratio or percent) and a Fixed Spacer for an exact pixel gap — cleaner than padding hacks.
The layout fields
Every widget's properties panel has a Layout section. It shows two groups: how this widget arranges its children (containers only), and how this widget places itself in its parent.
Arranging children (a Container or a Repeater — the widgets that flow their children) — Direction · Gap · Wrap · Align · Distribute · Padding · Radius. An Image Container hosts children too, but it pins them to points on the image rather than flowing them, so it has no row for any of these.
Placing itself — Width · Height.
Bounds — Min width · Max width · Min height · Max height, each under the axis it bounds. Floors and ceilings on the size the mode works out, for a Fill that must not get too narrow or a Hug that must not run away.
Every row is on screen from the start; nothing is folded behind a disclosure. A max-width you set once and then cannot see is a max-width that quietly wins arguments you are not having.
There is no row for the raw flex properties — no Basis, Grow, Shrink or Align self. Direction, Align and the two size modes cover that ground, and a panel that offered both would invite you to set the same thing twice and get a fight.

Width and Height
Each takes two rows: the mode — Hug, Fill or Fixed as segmented buttons — and the length beneath it, with that axis's Min and Max under those.
| Mode | The widget is |
|---|---|
| Hug | As big as its own content needs. |
| Fill | As big as the parent has room for — the rest of a row, the rest of a column, the rest of the screen. Two Fill siblings split the space evenly. |
| Fixed | Exactly the length you type. The length is only editable here; under Hug and Fill the size is derived, so the field shows a dash. |
Fill weight sits in its own row below Width and Height: how many shares of the leftover space this widget takes against its Fill siblings. Leave it empty for one share; set 2 to take twice what a plain Fill sibling takes. It appears as soon as either axis is set to Fill, and there is one weight for the widget rather than one per axis — which is why it sits below both rows instead of inside either. It only ever counts along whichever axis is currently the container's own direction; on the other axis the number is still stored and still editable, without anything rendering by it.
Flipping the container's Direction, or dragging the widget into a container that flows the other way, never deletes the weight, and never moves it onto an axis you didn't set to Fill yourself. Each axis's own mode decides on its own whether that axis reads the shared number — a flip only changes which axis the container treats as the one that matters, not what either axis's own mode says. So a weight on Height: Fill can stop rendering after a flip to a row-flowing container (Height is now the one that doesn't matter) without Width ever picking it up in its place — Width only reads it if Width's own mode is Fill too. The weight row keeps showing and editing the number regardless of which axis currently renders by it; when the only axis still reaching it is one you left unset, the row is cosmetic — it hedges in case that axis turns out to matter, but never actually renders the weight until you set it to Fill yourself.
Switching a mode to Hug or Fixed by hand only deletes the weight once no axis can reach it any more — and "reach" is wider than "still literally Fill": an axis left unset still reaches it too, the same default that lets a flip revive a weight, and a bound or multi-selected mode on the other axis is read the same cautious way, kept rather than risked. With the other axis still Fill, or still unset, or still unresolved, the number stays and the row stays with it.
Fill is how you make a widget as big as possible without scrolling: set Height: Fill on the widget, and on every container between it and the page. One container left on Hug is enough to stop the chain — it sizes to its content and has no leftover height to hand down.
Hug and Fixed hold their size when the container runs out of room: the container overflows rather than the widget being squeezed below the size you asked for. Only Fill gives space back, because giving space back is what it is for.
What the overflow looks like is the Container's Overflow setting. Visible (the default) lets the children spill past its edge, Clip cuts them off at the edge, and Scroll keeps them reachable with a scrollbar. Clip and Scroll need a container with a bounded size — Fixed or Fill; a Hug container grows to fit its children, so there is nothing to clip.
The mode you pick along the container's own direction is a different mechanism from the one across it — the editor picks the right one for you, which is the part that is easy to get wrong by hand.
Align and Distribute
Two rows of icons, one question each:
- Align — where the children sit across the direction of flow, or Stretch to have them fill it. Stretch is the default, and it is what a child's cross-axis Fill rides on.
- Distribute — where they sit along the direction of flow, plus Space between and Space around for spreading them out instead of bunching them together.
Each icon draws the arrangement it picks, and both rows follow the container's Direction: in a row the Align icons move children up and down, in a column they move them left and right. Hover an icon and it names the position you will get — Top, Middle, Bottom or Left, Center, Right — so you never have to work out which CSS property the direction turned it into.
Stack
Direction: Stack puts every child in the same spot, each drawn on top of the one before it in the tree — a product icon over a mat icon, a status badge over a picture. The container is as big as its largest child, and a child with Width: Fill or Height: Fill stretches over the whole container on that axis. Align places each child top to bottom, Distribute left to right; Gap and Wrap leave the panel, since a stack neither spaces nor wraps. A Repeater has no Stack: its copies would all land on one spot. Hide a child with Visible and the ones under it show through.
Padding
Four rows — Padding top, right, bottom and left — one per side, each independent. Clear one and it falls back to the theme's own default padding, never to whatever the other three happen to share, so emptying a side can't silently borrow a number from its neighbours.
There is no Margin. Spacing between widgets is the parent's job — a container's Gap and Padding — which is one place to look instead of two that add up.
Length fields take any CSS length — 200px, 100%, 12rem, auto — so you can mix fixed and proportional freely. Leave a field empty and it inherits: Radius falls back to the theme's --hmi-radius rather than to a hardcoded value.
Layout fields are properties like any other, so they can be bound. A sidebar's width driven by $if, a gap that changes with $viewport, one padding side driven by a variable while the other three stay fixed — no CSS involved. Every layout row carries the same source pill as any other property, so binding one is the same gesture everywhere.
Persistent chrome: shell regions
A page's content scrolls, but navigation and status shouldn't. The shell wraps every page with four fixed regions you fill once:
- Header ▣ — Top bar — page title, alarm summary, language switcher.
- Left / Right sidebar ◧ ◨ — Navigation menu, quick actions, live KPIs.
- Footer ▄ — Status line, connection state, clock.
- Content — The active page renders here; the shell stays put around it.
Each region appears in the page tree above the page list. Select one and its properties give you the region's behaviour, not just its contents:
| Setting | Does |
|---|---|
| Enabled | Whether the region exists at all. |
| Expanded size · Collapsed size | The region's size in each state — width for a sidebar, height for header/footer. |
| Expanded | Whether it is currently open. Bindable, so a menu-toggle button or a tag can drive it. |
| Default state | expanded, collapsed, or hidden on load. |
| Overlay | When expanded, float over the content instead of pushing it aside — the usual choice on a phone. |
| Full height (sidebars) | Span the whole layout height, flanking the header and footer instead of sitting between them. |
| Floating | Lie over the page instead of next to it. The page fills the space underneath, the region is transparent unless it has a Background, and a click on its empty space reaches the page. Widgets in the region, a Button Container included, still take their own clicks. A floating header or footer covers only the page, never a sidebar. Keep page content clear of the widgets yourself, for example with page padding. |
| Background | The region's background colour. |
Those settings are project-wide, and every one of them except Default state is bindable — so a region that should behave differently on one page gets a binding, not a per-page copy of the settings. Bind Enabled to an $if over $pageIsActive and a full-screen trend, or a login screen with no navigation at all, costs one expression on the region instead of a setting repeated on every page.
Project-wide shell settings also cover the browser tab title, the favicon (a path under assets/, see Files & assets), an HMI scale factor that zooms the entire layout for a small or very large panel, and Locked feedback — what an operator gets when they press a widget that is not Interactable (see Users, groups & access). The boot screen's branding is fixed in the open-source build (see Licence).
Place freely over an image
For a P&ID, a floor plan, or a machine photo, use an Image Container instead of a flex Container. It hosts children at absolute positions on top of a background image — a valve indicator here, a temperature readout there. Set Fit for how the image scales, and Collapse below to a pixel width under which the absolute placement is abandoned and children stack normally, so a phone doesn't get a postage-stamp overview.
Place one design many times from a list
Twenty-seven door switches on a machine photo are one design drawn twenty-seven times. Put a Repeater in the Image Container, set its Place copies to At item x / y, and give it a list of elements that each say where their copy goes:
[
{ "x": 0.104, "y": 0.278, "side": "top", "label": "6.1", "device": 0 },
{ "x": 0.906, "y": 0.352, "side": "right", "label": "6.7", "device": 6 }
]
x,y— the point on the image, from 0 to 1 across and down, the same numbers Place children… stores for a single child. A copy without them sits in the middle.side(optional) —top,bottom,leftorright: the copy sits on that side of its point, stacked so that its last widget touches the point. Design one copy outward-in — tag, button, lead line, the edge mark last — and every copy ends on its own edge of the machine, whichever side that is. Withoutsidethe copy is centred on its point and lined up by the Repeater's Layout.- Anything else is yours: inside the copy, read it with Repeat Item (a label, a dialog's input), or use it as the index of an array tag —
{"$var": {"path": "CF:Safety/Open", "index": {"$repeatItem": {"member": "device"}}}}reads the element the copy'sdevicenames.
Type the list into Items with Edit as JSON (a list whose elements carry more than a label and a value opens that way), or bind Items to an array tag of structs with x and y members. The positions live in the list: on the editor canvas the copies are drawn where they will appear, but are moved by editing their numbers, not by dragging. The warnings pill flags a typed-in element whose x, y or side cannot place it. Below Collapse below, the copies stack in the list like any other child.

One project, every screen size
Rather than maintaining separate designs, branch any property on the viewport. Bind it with $viewport and choose a value per size class — no media-query CSS anywhere.
// stack on a phone, sit side-by-side on a laptop
"direction": {
"$switch": {
"value": { "$viewport": { "field": "size" } },
"cases": [
{ "when": "phone", "then": "column" },
{ "when": "tablet", "then": "column" }
],
"default": "row"
}
}
$viewport also exposes orientation, width and height. Preview the result with the viewport selector's Phone / Tablet / Laptop presets before you ship.
A few patterns worth reaching for:
- Collapse the sidebar on small screens — bind the region's Overlay and Default state to
$viewport, and put a Menu Toggle in the header. - Hide detail rather than shrink it — bind a widget's
visibleto$compareagainst$viewport'swidth. A cramped widget reads worse than an absent one. - Scale, don't redesign, for an odd panel — the shell's HMI scale factor handles a 7" panel or a 4K wall display without touching a single layout field.