JsonUI
← ConceptsResponsive designOne Layout JSON should not look the same on a 5-inch phone, an 11-inch tablet, and a split-screen iPad. The `responsive` block lets a single component swap attribute values per size class — without forking the layout file. Here is how the resolver picks which set of values wins, what the generator emits per platform, when responsive is the wrong tool — and the `home@regular.json` variant files that swap the whole tree when it is.~7 min read
The problemA vertical stack with 8 px spacing reads fine on a phone, but on an iPad in landscape the same stack feels cramped — the screen has the room to lay sections out horizontally with 24 px spacing. When only attribute values differ, forking the whole file into `home.json` and `home@regular.json` doubles the maintenance cost for a spacing tweak; doing all the math at the ViewModel layer leaks layout concerns into business code. The `responsive` block is the third option: keep one Layout JSON, declare the deltas, let the runtime pick. (When the structure itself changes per size class, the whole-file fork IS the right tool — that is the variant-file mechanism covered below.)
Six size-class keysThe resolver recognizes a fixed set of keys. Each maps to a host-platform predicate that the runtime evaluates on every layout pass.• `compact` — narrow width. iPhone portrait, foldable inner display closed, web < 768 px.• `medium` — tablet portrait, split-screen iPad, web ≈ 768–1023 px. iOS has no native medium class — falls back to `compact`.• `regular` — wide width. iPad landscape full-screen, web ≥ 1024 px.• `landscape` — vertical size class is compact, regardless of width axis. Phone in landscape orientation.• `compact-landscape` / `regular-landscape` — composite keys. Match only when BOTH conditions hold; rank above the single keys in the priority table.
The `responsive` blockDrop a `responsive` object next to the base attributes. Keys are size-class names; values are partial attribute objects that override the base when the host matches that key. The base attributes are the default — used when no key matches.
layout.json
{
"type": "View",
"orientation": "vertical",
"spacing": 8,
"responsive": {
"regular": { "orientation": "horizontal", "spacing": 24 },
"landscape": { "spacing": 16 },
"regular-landscape": { "orientation": "horizontal", "spacing": 32 }
},
"child": [ /* … */ ]
}
Resolution priorityMultiple keys can match a single host (an iPad in landscape matches `regular`, `landscape`, AND `regular-landscape`). The resolver walks the priority list from highest to lowest, overwriting earlier keys with later ones. Composite keys always win because they are checked first; the base layer is the floor.
resolution.txt
// Resolution order, highest priority first:
// 1. composite key (compact-landscape, regular-landscape)
// 2. landscape
// 3. regular
// 4. medium
// 5. compact
// 6. base attributes (no key)
//
// Each step OVERWRITES (not deep-merges) keys from earlier steps.
// On iPad regular-landscape, given the JSON above, the resolver yields:
// orientation: "horizontal" ← from regular-landscape
// spacing: 32 ← from regular-landscape
// On iPhone landscape:
// orientation: "vertical" ← from base
// spacing: 16 ← from landscape
// On iPhone portrait:
// orientation: "vertical" ← from base
// spacing: 8 ← from base
Generated mode: wrapper functionsIn generated mode each platform's converter inlines the resolution at compile time. SwiftUI lifts the responsive container into a `@ViewBuilder` helper to keep type-checker work bounded; Compose does the same with `@Composable`. The children are emitted exactly once and threaded through `content()`, so an inner `Collection` or `Label` is never duplicated even when six size-class branches exist on the parent.
Generated.swift
// SwiftUI — the converter lifts the responsive container into a
// @ViewBuilder helper, then injects the children via `content()`.
// Children are emitted ONCE; only the wrapper branches.
@ViewBuilder private func responsiveContentArea<Content: View>(
@ViewBuilder content: () -> Content
) -> some View {
if horizontalSizeClass == .regular {
HStack(spacing: 24) { content() }
} else {
VStack(spacing: 8) { content() }
}
}
 
var body: some View {
responsiveContentArea {
section0_0()
section0_1()
}
}
Generated.kt
// Compose — same pattern. Wrapper is @Composable; children pass through.
@Composable
private fun ResponsiveContentArea(content: @Composable () -> Unit) {
val widthClass = currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass
if (widthClass == WindowWidthSizeClass.EXPANDED) {
Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) { content() }
} else {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { content() }
}
}
Generated.tsx
{/* React — Tailwind breakpoint prefixes do the work; landscape uses
a useMediaQuery hook for the orientation predicate. */}
<div
className={[
'flex flex-col gap-2', // base / compact
'lg:flex-row lg:gap-6', // regular
isLandscape && 'gap-4', // landscape
isLandscape && 'lg:flex-row lg:gap-8', // regular-landscape
]
.filter(Boolean)
.join(' ')}
>
{children}
</div>
Dynamic mode: tree resolutionDynamic mode (the runtime that powers hot reload on iOS and Android) takes a different path. A shared `ResponsiveResolver` walks the JSON tree once at mount time, flattens every node by merging the matching `responsive` keys back into the parent, and hands a plain JSON dictionary to the per-component converter. Individual converters never know `responsive` exists — they see the resolved attributes only. SwiftJsonUI keys off `@Environment(\.horizontalSizeClass)` and `verticalSizeClass`; KotlinJsonUI uses `currentWindowAdaptiveInfo()` plus `LocalConfiguration.orientation`. Both rely on the platform's own change-tracking to re-run resolution when the host rotates or splits.
Variant files — `home@regular.json`When the structure itself must change per size class, ship a whole alternative tree instead of overrides: keep `home.json` as the canonical base and place `home@compact.json` / `home@medium.json` / `home@regular.json` next to it. Those three suffixes are the entire vocabulary — landscape and composite keys remain inline-`responsive` territory, and `@tablet` is rejected with a dedicated "did you mean '@regular'?" hint. Resolution is exact-match or base, never a neighboring tier: a medium window with only `@regular` shipped renders the base, and iOS — which has no medium size class — folds `@medium` into its compact tier with `@compact` winning. Tier detection is identical to the inline `responsive` block (iOS horizontal size class, Android 600 / 840 dp, web 768 / 1024 px).
variant resolution
Layouts/
├── home.json ← canonical base (data section lives here)
├── home@compact.json ← optional whole-tree swap, compact tier
└── home@regular.json ← optional whole-tree swap, regular tier
 
// tier → file: exact match or base — no promotion between tiers
// regular window → home@regular.json
// medium window (no @medium shipped) → home.json
// iOS (no medium class): @medium folds into compact; @compact wins
Variants declare no `data` and no `platforms` — the data contract stays base-canonical: every binding a variant uses must be declared in the base, ViewModel / Data / spec are generated from the base alone, and `jui build` enforces all of it (vocabulary, orphan variants, `data` / `platforms` declarations, uncovered bindings) as hard errors. Because a tier change swaps the WHOLE tree, view-local state (scroll position, unbound input, focus) is lost by design while the single ViewModel instance — and every bound value — survives: state that must outlive a Split View / foldable transition belongs in a VM binding. In generated mode each variant compiles to its own `<Base><Class>VariantGeneratedView` sharing the base's Data / ViewModel; dynamic mode needs SwiftJsonUI 10.7.0+ / KotlinJsonUI 2.14.0+ — older dynamic runtimes never probe for `@` files and gracefully keep rendering the base.
When `responsive` is not the right toolThree nearby tools can all answer 'this should look different here'. They are not interchangeable.`responsive` — same component, different attribute valuesUse when the structure stays identical across size classes — same children in the same order — and only spacing, orientation, font size, column count, or similar attribute values need to flip.`platform` — omit or override per hostUse when a subtree is platform-specific — an iOS-only screenshot, a web-only sidebar, an Android-only system-bar inset filler. Give the node a string `platform` (`"platform": "ios"`, comma lists like `"ios,android"` too) and `jui build` drops it entirely on the other hosts. For per-host attribute values on a shared node, use the object form instead: `"platform": {"ios": {"height": 220}, "web": {"height": "100vh"}}`. (A `platforms` ARRAY is valid only on the layout root, where it whitelists which platforms the whole screen ships to.)Variant file (`home@regular.json`) — different structure entirelyWhen the children themselves change — phone shows a stack of cards, tablet shows a master-detail layout — `responsive` is the wrong shape because there is no shared structure to override. Ship a variant file (`home@regular.json`) next to the canonical base instead — the runtime swaps the whole tree when the size-class tier matches. Resolution, data contract, and state rules are covered in the variant-files section above.
Keep reading
One Layout JSON, three platformsRevisit the foundational claim now that you have seen how the resolver enforces it./concepts/one-layout-json
Hot reload everywhereHow a size-class change becomes a re-render without a rebuild./concepts/hot-reload