I've been working on uison, a lightweight UI framework in Rust, and wanted to share it:
- Tiny footprint: About 900 KB in size.
- Cross-platform: Runs on Windows (x64, ARM), macOS, Linux, Android, iOS/iPadOS, and WASM.
- Dual-mode rendering: Renders a graphical UI for humans or exposes the UI structure and actions via MCP (Model Context Protocol) as JSON, allowing AI agents to interact with it without screenshots.
- SSH UI Forwarding: Thanks to the JSON representation, you can easily pipe and forward the UI over SSH.
- Polyglot examples: Comes with usage examples in Rust, C, C++, and even ASM.
uisonlab
/
uison
A tiny (<900 KB) cross-platform Rust UI framework with C/C++/ASM bindings that compiles JSON layout diffs to native pixels, featuring MCP integration for AI agents and SSH UI forwarding.
uison
🇬🇧 English · 🇨🇳 中文 · 🇮🇳 हिन्दी · 🇪🇸 Español · 🇫🇷 Français · 🇸🇦 العربية · 🇧🇩 বাংলা · 🇧🇷 Português · 🇷🇺 Русский · 🇵🇰 اردو · 🇮🇩 Bahasa Indonesia · 🇩🇪 Deutsch · 🇯🇵 日本語 · 🇮🇳 मराठी · 🇮🇳 తెలుగు · 🇹🇷 Türkçe · 🇺🇦 Українська
uison (UI + JSON) is a tiny, fast UI framework written in Rust with no dependencies at all — only the standard library, down to its own JSON parser. You describe a screen as a tree of elements: in a JSON file, with a Rust DSL, with C++ widgets in the style of Flutter or as JSON right inside C code. The logic is ordinary code in Rust, C or C++: handlers, data, threads. uison takes care of the window, layout, drawing and input, and the same program runs on macOS, Windows, Linux (Wayland and X11), iOS, Android…
How I Built It
I used Opus 5.5 in Visual Studio Code to build the framework. My role was to come up with the idea, give direction, and review and verify the results.
It took about three weekend days and roughly a week of evenings in my free time outside work. I tested it on all supported platforms, both automatically and manually.
The Key Idea: JSON Everywhere
Although passing JSON around has become something of a running joke, in this case I think it's justified. The protocol is simple from the lowest level up, so you don't have to spend much time learning it, and here that matters more than rendering speed (the framework is not for complex graphics).
The lowest layer of the framework can render only a JSON frame consisting of 9 primitives. Here they are:
| Primitive | Geometry | Properties | Description |
|---|---|---|---|
rect |
[x, y, w, h] |
fill, stroke, width, radius |
Rectangle, including rounded corners |
line |
[x1, y1, x2, y2] |
color, width, cap (butt / round) |
Line segment |
circle |
[cx, cy, r] |
fill, stroke, width |
Circle |
tri |
[x1, y1, x2, y2, x3, y3] |
fill, stroke, width |
Triangle (arrows, checkmarks) |
poly |
[[x, y], [x, y], …] |
fill, stroke, width |
Closed polygon (chart sectors, custom shapes) |
text |
"one line" |
at: [x, y], size, color, font, bold, italic |
Line of text; wrapping is handled by the screen layer |
image |
[x, y, w, h] |
src, fit (cover / contain / fill), radius |
Image |
clip |
[x, y, w, h] |
items: […] |
Group clipped to a rectangle (scrolling) |
hit |
[x, y, w, h] |
id, cursor |
Invisible area for mouse and touch input (not drawn) |
At the next level up, familiar controls such as Row, Text, IconButton, and others are added to JSON. As JSON containing complex elements passes down to the lowest level, it is transformed so that only these 9 primitives remain.
All of this made it possible to quickly adapt the framework to all 6 platforms known to humanity: Windows, macOS, Linux, Android, iOS and Browser (WASM).
Custom Controls
At the highest level, users can add their own controls in Rust or directly in JSON. At the lowest layer, the new controls become JSON consisting of the same 9 primitives.
Render Only What Changed (Damage Tracking)
Frames are compared element-by-element using LCS alignment to generate "damage" regions. The backend then redraws only those modified regions instead of the whole screen.
Thanks to built-in list virtualization, an inbox with 2,000 emails only ever renders the ~20 visible rows at any given time.
Building on this concept, I even made a lightweight web browser. It supports a subset of HTML and CSS, which is parsed into JSON and fed straight to the framework. uison knows nothing about HTML or CSS — it just receives standard JSON and renders it like any native UI.
UI over SSH
Since the interface is native JSON, your app can run completely headless. The program on the server knows nothing about SSH: it writes frames to stdout and reads user events from stdin. The server needs only sshd — no X11, VNC, or browser.
The same screen as a raw JSON stream:
Taking It Further: Native Modes for AI Agents
The exact same binary runs in multiple modes with zero code changes: open a graphical window for humans (default), stream raw JSON over SSH (--console), or expose semantic state to AI agents and scripts (--agent / --mcp).
With --mcp, your app becomes a server for Claude Code or Cursor. Agents inspect the UI via snapshot, send actions like click or type, and receive only lightweight diffs, all while respecting UI rules like disabled buttons.
Size
Comparing two versions of a real open-source Disk Usage application for macOS, both written in Rust:
- egui version: 5.1 MB
- uison version: 0.9 MB (an 82% reduction in application size)
Both release builds use LTO, codegen-units = 1, panic = "abort" and strip = true. The uison version uses opt-level = "s", the egui version opt-level = 3. Part of the difference is that uison uses the system's text and graphics (CoreText, CoreGraphics on macOS) instead of bundling fonts and an OpenGL renderer.
Quick Start
Everything is covered in the tutorials: Rust, C, C++, and a brief introduction to ASM. Here I'll show how to get started with Rust.
Step 1. Create a project and add uison
cargo new hello && cd hello
cargo add uison
Step 2. Replace src/main.rs
use uison::prelude::*;
fn main() {
let screen = center(column().gap(12).cross("center").children([
text("Pressed {@n} times").size(20),
button("+1").key("inc").on_click(|cx| cx.data.edit("n", |n: &mut i32| *n += 1)),
]));
let mut ui = Ui::from_node(screen).with_size(320.0, 200.0);
ui.data.set("n", 0);
run(ui, "Hello");
}
Step 3. Run it
cargo run --release
A window with a counter will open. {@n} is a data binding: the handler changes n, and the text updates automatically, without manual redrawing.
Status
The project is young (version 0.2.1) and the API may change. It's MIT licensed. Not done yet: accessibility (screen readers), IME for Chinese, Japanese and Korean on the desktop, right-to-left text, and more than one window per program.
Links
For anyone interested, everything you need can be found at these links:
- GitHub repository: uisonlab/uison
- Tutorials
- SSH player for running a program remotely: uisonlab/uison-ssh-player
- WASM demo on GitHub Pages
I'd be glad to hear feedback on the API and the JSON format. Issues and stars are welcome!



Top comments (2)
Question on the JSON format: are element ids stable across re-renders, or does the agent need to re-read the whole tree after every action? One thing worth considering: have each action return only what changed, so the agent can confirm the click landed without fetching the full UI again. (I'm on the Auten team; we build a computer-use MCP that drives existing apps through the screen, so stable references are the part we fight with most.)
🚩