The project's README from github.com/justrach/nanoui. As one markdown file: README.md.
Desktop apps in Zig, with native UI drawn on the GPU or a web frontend.
Meet Nano. Transparent PNG · Original paper illustration
Want to see it in use? nanoterm is a terminal with a coding agent, and nanomon an activity monitor, both built on nanoui.
Get started · Native UI · Web pages · Testing · Platforms · Apps · Performance · Credits
Each window shows one of two kinds of interface:
Apps are single binaries. Nothing polls, so an idle app uses no CPU.
git clone --recursive https://github.com/justrach/nanoui
cd nanoui
zig build run-gallery # the native UI tour
zig build run-hello # the webview example
zig build test # unit and widget tests
You need Zig 0.17.0, plus the macOS SDK on a Mac (Xcode or the Command Line Tools). Linux apps need GTK 3 at run time and nothing at build time.
--recursive also fetches the apps, which live in their own repositories as
submodules under apps/. To fetch them after a plain clone, run
git submodule update --init.
To use nanoui in your own project, add a release to its build.zig.zon:
zig fetch --save git+https://github.com/justrach/nanoui#v0.2.0
Releases are tagged vMAJOR.MINOR.PATCH, as mygo's are, and each is listed
under Releases with what
changed. While nanoui is 0.x, a minor release may change the API; a patch
release won't.
const Counter = struct {
n: i64 = 0,
fn view(s: *Counter, c: *ui.Context) void {
const col = ui.div(c).column().sizeFull().center().gap(16);
defer col.end();
ui.textf(c, .{ .size = 56, .weight = .bold }, "{d}", .{s.n});
if (ui.button(c, "Increment", .{ .variant = .primary }).clicked) s.n += 1;
}
};
pub fn main(init: std.process.Init) !void {
const app = try nanoui.App.create(init, .{ .name = "Counter" });
defer app.destroy();
var state: Counter = .{};
_ = try app.newWindow(.{ .title = "Counter", .content = .{ .view = .init(&state, Counter.view) } });
try app.run();
}
The gallery example, zig build run-gallery.
Views are immediate mode: a function of your state, run on every event. Widgets answer from the previous frame's layout. When an interaction changes state, the frame is built again before it's drawn, so the screen never lags the state.
ui.div(c).row().gap(8).bg(...).ui.list) that builds only the rows in view.:hover does, double
clicks, and pointer tracking for graphs (c.wants_moves)..glass(.{}) fills a box with Liquid Glass, the material of macOS 26 and
later, in place of a background. Whatever was painted under the box shows
through it: frosted, bent near its edges as through the rim of a lens, and
lit along a hairline rim. That includes content scrolling under a toolbar
placed over it with absolute().
const bar = ui.div(c).absolute().offset(20, 20).w(420).h(56).row().itemsCenter().px(16).rounded(28).glass(.{});
defer bar.end();
ui.text(c, "On glass", .{ .weight = .semibold });
ui.spacer(c);
// A prominent button: glass tinted with the accent.
const tap = ui.div(c).px(16).py(8).rounded(16).glass(.{ .tint = th.accent }).control();
defer tap.end();
ui.text(c, "Tap", .{ .color = th.accent_text });
Regular glass frosts and lightens what shows through, or darkens it in dark
mode, so text on it reads over anything. .style = .clear barely frosts it,
for glass over photos and video. nanoui draws the glass itself, after mygo's
glass plugin and its measurements of AppKit's NSGlassEffectView. Each pane
averages and blurs what's under it, then shades it on the GPU, and the CPU
renderer computes the same pixels. It looks the same on Linux and Windows,
and a test checks the two renderers agree.
The gallery's Glass page.
const Greeter = struct {
pub const nanoui_params = .{ .greet = .{"name"} };
pub fn greet(_: *Greeter, call: *nanoui.Call, name: []const u8) ![]const u8 {
return std.fmt.allocPrint(call.arena, "Hello, {s}!", .{name});
}
};
const Tick = nanoui.Event("tick", struct { uptime: u64 });
// in main:
try app.bind(&greeter);
try app.registerEvent(Tick);
_ = try app.newWindow(.{ .title = "Hello", .content = .{ .page = .{ .url = "/", .assets = &assets } } });
import { Greeter, onTick } from "/nanoui.js"; // generated, served by the app
document.body.textContent = await Greeter.greet("Ada");
onTick(({ uptime }) => console.log(uptime));
zig build gen writes nanoui.d.ts:
export declare const Greeter: { greet(name: string): Promise<string> };
export declare function onTick(listener: (payload: TickPayload) => void): () => void;
Methods may return error unions; an error rejects the page's promise with
the error's name. Assets are served from the nanoui://localhost/ scheme.
Elements styled --app-region: drag move frameless windows.
var t: ui.Tester = undefined;
try t.init(gpa, .init(&state, Counter.view), .{});
defer t.deinit();
try t.clickText("Increment");
try std.testing.expect(t.hasText("1"));
try t.screenshot("/tmp/counter.png"); // rendered offscreen by the Metal renderer
The real apps run headless too:
zig-out/bin/gallery --nanoui-snapshot=shot.png --nanoui-script="click:Tasks;type:Hi;key:enter"
zig-out/bin/hello --nanoui-eval='nanoui.call("Greeter.greet","Ada").then(v => { nanoui.log(v); nanoui.window.close() })'
nanoui loads what each platform has instead of linking it, so every platform
builds from any machine. On a Mac, zig build -Dtarget=x86_64-linux-gnu or
-Dtarget=x86_64-windows-gnu makes a Linux or Windows binary.
| macOS | Linux | Windows | |
|---|---|---|---|
| windows, input, timers | AppKit | GTK 3 (loaded with dlopen) |
Win32 |
| text | CoreText | nanoui's TrueType reader (src/ui/tt/) |
the same |
| rendering | Metal | CPU (src/ui/raster.zig), shown with cairo |
CPU, shown with GDI |
| icons | SF Symbols | line icons drawn by nanoui (src/ui/icons.zig) |
the same |
| pages (webview) | WKWebView | not yet | not yet |
| menus | the menu bar | accelerators | accelerators |
The CPU renderer matches the Metal one pixel for pixel, and a test compares
them. zig build test -Dportable=true runs the Linux and Windows text and
renderer on a Mac. Linux is also tested in a container
(tools/linux.Dockerfile: GTK, fonts and a virtual display).
nanoterm: a terminal of blocks, in the manner of Warp, with a coding agent (graff or Claude Code) over ACP. It has native visuals, a ⌘P launcher, and SSH sessions where the agent works on the remote host. |
nanomon: an activity monitor in Codegraff colors, with cell graphs of CPU, memory, GPU, disk and network. You can quit several processes at once, and a graff bubble suggests what to quit. |
Download them for macOS: nanoterm · nanomon.
Both live in apps/ as submodules, and each also builds on its own, fetching
nanoui pinned by commit. To build one against the nanoui next to it, with
your changes, use --fork: cd apps/nanomon && zig build run --fork=../...
Performance is measured headless with hillclimb/:
the same scenes rendered through each framework's own frames and offscreen
Metal renderer, on an Apple M3 Ultra. The figures are medians per frame in
milliseconds; lower is better.
| scene | frame CPU nanoui | frame CPU mygo | GPU nanoui | GPU mygo |
|---|---|---|---|---|
| boxes (600 shadowed boxes) | 0.061 | 0.301 | 0.140 | 1.616 |
| text (300 changing labels) | 0.108 | 0.418 | 0.065 | 0.056 |
| scroll (1000-row column) | 0.315 | 2.076 | 0.055 | 0.122 |
| list (100 000-row virtual list) | 0.060 | 0.367 | 0.046 | 0.045 |
| dashboard (12 cards) | 0.015 | 0.087 | 0.098 | 0.295 |
Release binaries are about 0.7 MB for nanoui and 11.8 MB for mygo. IPC (5000 sequential calls from the page to native code) took 493 ms with nanoui and 528 ms with mygo.
zig build lines # the 600-line limit, enforced
zig build test-bin -Dtarget=aarch64-linux-gnu --prefix zig-out/linux # Linux tests, for the container
cd hillclimb && zig build run -- --label "change" # benchmarks against mygo and history
sh tools/version.sh 0.2.1 # set the version for a release
To release, set the version, commit, and push its tag (git tag v0.2.1 && git push origin v0.2.1). The release workflow checks that the tag matches
build.zig.zon, runs the tests, and opens the GitHub release.
Rules for working here, by hand or with an agent, are in AGENTS.md.
nanoui is a subset of what full frameworks offer. Pages work only on macOS so
far, and there's no accessibility, bundling, notifications, channels or
plugins yet. Apps update themselves from GitHub releases on macOS
(nanoui.Updater). Bound methods run on the main thread.
nanoui is licensed under the MIT License. Material from mygo (MIT) and GPUI (Apache 2.0) keeps its own notices; see third-party notices.
nanoui stands on the shoulders of two projects:
uniform_list;Built in Zig 0.17 · AGENTS.md · benchmarks · MIT · third-party notices