Micro OS runs the same compiled app on an ESP32-S3 panel, in a macOS window and in a browser tab. The pixels are not identical — the fonts and DPI differ — but the tree, the state and the interaction model are.
That only works because the UI is not a scene graph that apps mutate freely. It is a static tree with reactive values.
Mount once
ui.mount builds the tree once. Every widget is declared up front: columns, rows, text, buttons, inputs, switches, sliders, progress bars, icon badges, tab views. The compiler knows the full shape at build time, and the runtime can validate references before the first frame.
After mount, the tree is immutable. There is no createElement, no dynamic list, no widget constructed inside a handler. This is the single biggest constraint in the app language, and it is what makes the rest tractable.
Bindings, not diffs
Dynamic behavior comes from state and bind. A binding is an expression that reads one or more state slots. The runtime evaluates it while recording those reads, so it knows exactly which slots the binding depends on.
When a handler writes state, only the subscribed bindings are marked dirty. At the end of the handler, dirty bindings are re-evaluated once, their writes are coalesced, and the runtime emits patches for the existing nodes:
state.count = 3
→ binding "Count: 3" dirty
→ RenderPatch::SetText(node 12, "Count: 3")
→ renderer updates the existing label
There is no virtual DOM and no diff of the whole tree. The dependency graph is the diff.
Layout without a layout engine
The layout model is intentionally plain. Columns and rows are containers, but children are positioned with ui.place using explicit left, top, width and height, plus optional anchors. An anchor can pin one edge or stretch between two. The compiler lowers align sugar into the same anchor representation.
That sounds primitive compared with flexbox, and it is. But it buys three things:
- The same geometry can be computed identically in Rust, in LVGL, and in the DOM.
- There is no measure pass whose result depends on font metrics the device may not have.
- A layout bug is reproducible on every host.
Rows and columns still provide flex-like distribution for their children where the design calls for it, but the app cannot rely on implicit auto-layout for absolutely positioned content. The rule is explicit: if a widget needs a position, it gets one.
The renderer port
The runtime owns the tree and the patches. A host implements a narrow port:
| Host | Renderer | Backing |
|---|---|---|
| ESP32-S3 | micro-lvgl |
LVGL 9.5 objects |
| macOS | micro-lvgl |
LVGL 9.5 + SDL3 window |
| Web | micro-renderer-web |
DOM nodes |
The port maps a MicroUiTree to native objects and applies patches such as SetText, SetVisible and SetIcon. It does not interpret app code, and it does not decide when to re-render.
Host chrome sits outside the app tree. Every screen gets the same 40 px app bar — Wi-Fi on the left, title centred, clock and close on the right — and app content mounts into the remaining 440 px (landscape) or 760 px (portrait). The app never draws the bar; it gets it for free.
The details that make it feel native
Two details are easy to miss and expensive to retrofit.
Icons are names, not assets. Apps declare ui.icon("settings"), and each host resolves the name to its own artwork: a Font Awesome subset glyph on LVGL, an inline stroke SVG on the Web. The full 42-icon catalog costs about 21 KiB of flash on device, not the ~110 KiB that four complete symbol faces would need.
Text is a shared asset. The embedded faces cover printable ASCII, common Chinese punctuation, 3,755 GB2312 level-1 Han characters and IPA, with a pinned Noto fallback for IPA glyphs missing from the CJK face. The compiler rejects unsupported literal glyphs rather than shipping a box.
The cost
A static tree cannot express an unbounded list, a dynamically composed form, or a custom-drawn chart without adding new widget kinds. Every new capability is a format change and a version bump. That is slower than handing apps a canvas.
The payoff is a UI contract that three renderers can implement without guessing, and a class of bugs — “works in the browser, broken on the panel” — that mostly cannot happen by construction.
For this slice, that is the right trade. The tree is small, the patches are explicit, and the app runs where you put it.