← GuidesWriting layoutsLayout JSON is the single biggest file family you author in a JsonUI project, and a handful of idioms make it much easier to read. The same JSON ships to iOS (SwiftUI / UIKit), Android (Compose / XML), and Web (React) — each platform's generator projects it into native code, but the vocabulary you author in is one. This guide walks the cross-platform essentials: how styles get shared, how sub-layouts are reused via include, how Collection cells and sections work, the @{binding} and visibility story, and the pitfalls every first-time layout author trips over.~18 min read
1. Orient yourselfFour locations matter when you author a layout. The primary layout JSON you edit; the cells/ directory with the reusable row layouts a Collection points at; the styles/ directory with shareable attribute bundles; and strings.json with the translations that layouts reference by key. All four live under docs/screens/ — the project root is where jui build reads from, and each platform gets a copy distributed to its own tree.
project tree
docs/ screens/ json/ <name>.spec.json ← authored screen_spec layouts/ <name>.json ← authored layout JSON (source of truth) cells/ <cell_name>.json ← reusable row layouts for Collections Resources/ strings.json ← en / ja translations, keyed by <namespace>_<key> styles/ <style_name>.json ← shared attribute bundles2. Style definitionsA style is a plain JSON file under docs/screens/styles/ that carries component attributes you want to share — colors, paddings, font weights, rounded corners — without copying them into every Label or Button. Attach it with the 'style' attribute by file name (no path, no extension). At build time each platform's generator deep-merges the style into the component; on any attribute collision, the value you wrote inline on the component wins, so you can override a shared style on one specific screen without editing the style file. The rule holds whether the target is React, SwiftUI / UIKit, or Compose / XML.• Style files are flat — there is no 'extends' or 'parent' mechanism. If you want composed behaviour, wrap the component in a View that carries the outer style and put the inner style on the child.• If the style file declares a 'type' and the component also does, the component's type wins. This lets a style file name itself a Button without preventing a View from picking it up (the type is treated as a hint, not a constraint).
docs/screens/styles/primary_button.json
{ "type": "Button", "width": "wrapContent", "height": "wrapContent", "paddings": [12, 24, 12, 24], "background": "accent", "fontColor": "accent_ink", "fontSize": 16, "fontWeight": "semibold", "cornerRadius": 10, "tapBackground": "accent_hover"}docs/screens/layouts/home.json (excerpt)
{ "type": "Button", "id": "home_hero_cta_primary", "style": "primary_button", "text": "hero_cta_primary", "onClick": "@{onHeroInstallTap}"}3. Include (sub-layout reuse)When a piece of UI repeats across screens without being data-driven — a back button, a compact brand lockup, a footer disclaimer — write it once under layouts/ and reference it via the 'include' attribute. Each platform's generator emits a reusable unit in its native idiom — a React component on Web, a SwiftUI View struct (or a UIView subclass in UIKit) on iOS, a Composable function (or an Android View) on Android — named after the file (path/to/main_menu → MainMenu), and passes any shared_data / data fields on the include as props. This is distinct from Collection cells, which multiply one cell layout across rows driven by a data array; include is for single-insertion reuse.• include is one level deep — the included file's contents are not scanned for further 'include' directives at emit time. Keep sub-layouts simple and self-contained.
parent layout — include usage
{ "type": "View", "orientation": "vertical", "child": [ { "include": "common/back_button", "data": { "label": "< Back", "onBack": "@{onBack}" } }, { "type": "Label", "text": "title" } ]}docs/screens/layouts/common/back_button.json
{ "type": "Button", "width": "wrapContent", "height": "wrapContent", "paddings": [8, 12, 8, 12], "fontColor": "ink_subtle", "text": "@{label}", "onClick": "@{onBack}"}4. Collection basics (single cell type)A Collection renders a list or grid of cells driven by a data source. Three attributes are non-negotiable: items binds a CollectionDataSource from the ViewModel, cellIdProperty names the row field used as stable row identity (the React key on Web, SwiftUI's Identifiable conformance on iOS, the RecyclerView ViewHolder diff key on Android — without it identity falls back to the array index, which breaks diffing on reorders on every platform), and sections declares the cell layout path for each section. Everything else tunes the visual form — orientation, columnCount, lineSpacing / itemSpacing, lazy, scrollEnabled.Layout attributes worth knowing• orientation — 'vertical' (default) stacks rows top-to-bottom; 'horizontal' lines cells up in a row (common for chip strips and tab bars).• columnCount — integer > 1 turns the Collection into a grid of that many columns.• lineSpacing / itemSpacing — gap between rows (vertical) and between cells in a row (horizontal). You can also set spacing as a single-value fallback for both.• lazy — the Collection's container mode: `'lazy'` (default; virtualized, scrolls itself), `'eager'` (every row rendered inside a scroll container) or `'none'` (no scroll container of its own — the parent must scroll; use it inside a page-level Scroll, the common case). The value is a string or a binding; a boolean is not accepted, and the build warns `Attribute 'lazy' in 'Collection' expects string or binding, got boolean` (measured at 1.8.20). For `layout: "flow"` the rule since jsonui-cli 1.8.20: with lazy in effect (`'lazy'` or `'eager'`) the flow Collection scrolls vertically inside its own bounds; with `'none'` it only wraps.• scrollEnabled — false disables the Collection's own scroll gesture entirely; useful when the Collection should size to its content exactly and not intercept the parent's scroll (every platform honours this the same way).On the cell side, the layout starts with a 'data' block that declares the shape of the row the parent will pass in. Each platform's generator reads this block and emits a typed wrapper in its native language — a TypeScript interface on Web, a Swift struct on iOS, a Kotlin data class on Android — all named `<Cell>Data` with matching field names and types. The cell's bindings and the ViewModel's row construction stay type-aligned against that shared shape. You populate the data in the ViewModel and call CollectionDataSource.setCells(sectionIndex, rows); each platform then renders one cell per row using cellIdProperty for identity.
home.json (Collection excerpt)
{ "type": "Collection", "id": "home_agents_collection", "width": "matchParent", "height": "wrapContent", "topMargin": 16, "orientation": "vertical", "columnCount": 1, "lineSpacing": 12, "itemSpacing": 12, "lazy": false, "scrollEnabled": false, "items": "@{agents}", "cellIdProperty": "id", "sections": [ { "cell": "cells/agent_row" } ]}cells/agent_row.json (structure)
{ "type": "View", "orientation": "vertical", "child": [ { "data": [ { "name": "nameKey", "class": "String" }, { "name": "roleKey", "class": "String" }, { "name": "whenToUseKey", "class": "String" } ] }, { "type": "Label", "text": "@{nameKey}", "fontSize": 16 }, { "type": "Label", "text": "@{roleKey}", "fontSize": 11 }, { "type": "Label", "text": "@{whenToUseKey}", "fontSize": 13 } ]}AgentRowData — one shape, three platforms
// Web — src/generated/data/AgentRowData.ts (@generated)export interface AgentRowData { nameKey?: string; roleKey?: string; whenToUseKey?: string; cellId?: string;} // iOS — AgentRowData.swift (@generated — equivalent)struct AgentRowData { let nameKey: String? let roleKey: String? let whenToUseKey: String? let cellId: String?} // Android — AgentRowData.kt (@generated — equivalent)data class AgentRowData( val nameKey: String? = null, val roleKey: String? = null, val whenToUseKey: String? = null, val cellId: String? = null,)5. Collection multi-section (mixed cell types)One section = one cell type. None of the platform generators pick the cell layout dynamically per row. So to render, say, a featured card followed by a list of plain rows on the same Collection, declare two sections with their own cell: path, and have the ViewModel write to each section's cells array independently. The same applies to chat logs (user cell vs assistant cell), or a reference page that shows a summary row then a list of attribute rows. The rule is uniform across Web, iOS, and Android.• Tip: when a Collection has multiple sections, the item row that ships from the ViewModel must already be partitioned by section — Section 0 gets rows that fit its cell, Section 1 gets rows that fit its. Do not try to mix types in one section's cells array.
Collection with two cell types
{ "type": "Collection", "id": "reference_index_collection", "width": "matchParent", "height": "wrapContent", "orientation": "vertical", "items": "@{referenceSections}", "cellIdProperty": "id", "sections": [ { "cell": "cells/reference_featured_card" }, { "cell": "cells/reference_overview_row" } ]}6. Binding and visibilityAnywhere in a layout a string attribute value starts with @{name}, each platform's generator treats it as a binding that resolves at render time from the ViewModel's data. The same @{name} syntax works identically on iOS, Android, and Web — each generator wires it to the platform's native observable story (Combine publishers or SwiftUI @State on iOS, StateFlow or Compose State on Android, React state hooks on Web). Beyond text, the most commonly bound attribute is visibility, which accepts three values with different layout-tree consequences. Knowing which one you want prevents a whole class of layout bugs.• 'visible' — the default; the element renders normally and occupies its space.• 'invisible' — the element is not painted but still occupies its space, and it disappears from the accessibility tree (useful when you want a placeholder to preserve alignment). Each platform maps this to its own mechanism — visibility: hidden on Web, opacity 0 + accessibility-hidden on SwiftUI, alpha 0 with cleared semantics on Compose — so the consequence is the same everywhere. `hidden: true` is the boolean shorthand for exactly this state (it does NOT collapse the space).• 'gone' — the element leaves the layout tree entirely and its space collapses. Each platform realises this differently — a conditional render on Web, an `if` wrap inside the SwiftUI body, View.GONE in Android XML, or an `if` around the Composable — but the intent is uniform: the neighbours close the gap.
binding + visibility
{ "type": "Label", "id": "form_error", "visibility": "@{errorVisibility}", "fontColor": "#B91C1C", "text": "@{errorMessage}"}// errorVisibility: "visible" | "invisible" | "gone"7. Common pitfallsA short list of layout surprises that catch almost everyone once. None of these are bugs in the generators; they are direct consequences of how the cross-platform attribute vocabulary maps onto each platform's layout model (flexbox on Web, stack layouts on SwiftUI, Row / Column with weights on Android Compose). Knowing them is the fix.• weight vs matchParent inside a horizontal parent: in a horizontal View, matchParent on the cross-axis (height) means 'fill the row's height', not 'take all the remaining horizontal space'. If you want a child to claim unused main-axis space among its siblings, write weight: 1. The rule is the same on every platform — flex-row on Web, HStack on SwiftUI, Row on Android Compose: matchParent is cross-axis fill, weight is main-axis growth.• topMargin is orientation-agnostic: on every platform it always means 'space above this element', regardless of whether the parent flow is horizontal or vertical. In a horizontal View that adds space above the element, not between it and its left neighbour. Use leftMargin (or the parent's itemSpacing) for sibling gaps in a row.• 'gone' drops the element from the layout tree on every platform, 'invisible' keeps it: a sibling that depends on a conditional element's space preservation must read 'invisible', not 'gone'. Choosing 'gone' when you wanted 'invisible' quietly collapses alignment; choosing 'invisible' when you wanted 'gone' leaves an empty gap. The same trap exists with the `hidden` boolean: it is the shorthand for 'invisible', so it never collapses the space — use visibility 'gone' for that. The semantics are uniform across Web (conditional render vs visibility:hidden), iOS (if-wrapped body vs opacity 0), and Android (dropped Composable vs alpha 0).• _overlay is internal: you may see it surface in emitted code (the @generated React tree, for example) but you never write it by hand. When you need absolute or z-ordered positioning, use zIndex as a number — each platform's generator maps it to its native z-order mechanism (CSS z-index on Web, .zIndex() modifier on SwiftUI, elevation / zIndex on Android).• Duplicate layout basenames. Layout file basenames must be unique across the whole project: Data models and generated components are emitted flat per basename on every platform (SummaryRowData.swift / .kt / .ts), so `sales/summary_row.json` and `dashboard/summary_row.json` collide. `jui build` detects duplicates and aborts with the full pair list — fix by renaming one file (e.g. `sales_summary_row.json`) and updating its references. Cell layouts shared by several screens are the usual place this bites.
Keep reading
Developer menuThe DEBUG-only container that puts Dynamic Mode behind a long-press and hot-reloads the JSON you just authored. iOS + Android./guides/developer-menu
Writing your first specSpec drives the Data shape that layouts bind to./guides/writing-your-first-spec