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:
- Native UI, written in Zig alone and drawn by nanoui: no HTML, no JavaScript, no webview. It has flexbox layout, text in a glyph atlas, animations, and views you can test without a window. On macOS it draws with Metal and CoreText, in one instanced draw call per frame.
- A web page in the system webview (WKWebView, macOS for now). The frontend calls Zig through a JavaScript client generated from your Zig types, with TypeScript declarations.
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 testsYou 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.
nui makes an app that builds, and checks it without opening a window,
for people and for coding agents alike. It never asks anything, and every
command takes --json.
nui start my-app # build.zig, a view, its test and an AGENTS.md; then it builds
cd my-app
nui test # the view's tests, rendered offscreen
nui snapshot --out shot.png --script "click:Increment" # the app as a PNG, no window
nui run # its window
nui doctor # Zig 0.17, and GTK 3 on LinuxEach release has nui for macOS, Linux and Windows; zig build nui builds
it into zig-out/bin. nui start pins nanoui at the release nui came
with (--nanoui REF for another, --local PATH for a checkout). The app's
AGENTS.md tells an agent the commands and rules: check the look with
nui snapshot, the behavior with ui.Tester.
To add nanoui to a project of your own instead:
zig fetch --save git+https://github.com/justrach/nanoui#v0.5.0Releases 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.
What's in the toolkit
- Layout: rows, columns, cards and scroll views, with flexbox grow, shrink, absolute placement, and maximum sizes.
- Elements: GPUI-style method chains, such as
ui.div(c).row().gap(8).bg(...). - Lists: a virtualized list (
ui.list) that builds only the rows in view. - Text: wrapping, monospace, weights and rich spans, with text that can be selected and copied like a terminal's.
- Controls: buttons, checkboxes, toggles, sliders, progress bars, segmented controls, badges, and text fields with IME support.
- Images: pictures decoded once and packed into an atlas.
- Look: light and dark themes that follow the system, eased animations, Gaussian shadows, and Liquid Glass.
- Pointer: hover that follows ancestry as CSS's
:hoverdoes, 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.
ui.motion animates boxes the way gpui-motion does: every frame, you say
where a box should be, and motion takes it there.
- Transitions: a spring, or a tween with an easing.
- Interruptible: a target that changes midway is taken from where the box is, and a spring keeps its speed.
- Presence: boxes enter from
initial, and leave toexitbefore they're removed. - Gestures:
hoverandpressare targets too. - Lists: items can be staggered.
- Layout animation: boxes the layout moves glide to their new place.
- Keyframes: they loop, or play there and back.
Motion keeps its state in the frame's context, by box, so there are no objects to hold. It draws frames only while something moves, and jumps straight to the end when the system asks for reduced motion.
const f = ui.motion.frame(c, .{
.target = .{ .width = if (open) 320 else 180, .background = if (open) th.accent else th.surface },
.initial = .{ .y = 12, .opacity = 0 },
.press = .{ .scale = 0.96 },
.transition = .{ .spring = ui.motion.Spring.bouncy },
});
const card = ui.div(c).p(16).control().motion(f);
defer card.end();
if (card.clicked()) open = !open;
// A toast that comes and goes: drawn while it shows, and while it leaves.
if (ui.motion.present(c, "toast", saved, .{ .initial = .{ .y = 24, .opacity = 0 }, .exit = .{ .opacity = 0 } })) |t| {
const toast = ui.div(c).px(16).py(12).rounded(12).bg(th.text).motion(t);
defer toast.end();
ui.text(c, "Saved", .{ .color = th.background });
}nanoui.kit is a set of components after GPUI Kit, drawn by nanoui and
moved by ui.motion:
- Buttons in its variants (primary, danger, ghost, outline, link, …) and sizes, with icons, loading and tooltips.
- Display: tags, badges, avatars and stacks of them, alerts, skeletons, spinners, progress bars and rings.
- Overlays: tooltips, popovers, menus, dialogs, sheets and toasts. They open on layers over the page, go where they fit, and close on Escape or a click elsewhere.
- Navigation: tabs whose marker slides, breadcrumbs, pagination, and accordions that open to their content's height.
- Forms: radio groups, selects you can move through with the arrow keys, number inputs, and labelled fields with hints and errors.
const kit = nanoui.kit;
if (kit.button(c, "Delete", .{ .variant = .danger, .icon = "trash" }).clicked) confirm = true;
if (kit.dialog(c, "confirm", &confirm, .{ .title = "Delete project?", .ok = "Delete", .ok_variant = .danger })) |d| {
ui.text(c, "Its files and history go too.", .{});
if (d.end() == .ok) toaster.push(.{ .status = .danger, .title = "Deleted" });
}
_ = kit.tabs(c, &.{ "Overview", "Activity", "Settings" }, &tab, .{ .variant = .segmented });
toaster.view(c); // last: toasts over everythingOverlays are made of two new box properties: layer draws a box above the
page and unclipped, and fixed places it from the window's corner.
Context.state keeps a widget's small state between frames.
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 rendererThe 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 (iOS from a Mac with Xcode, for its SDK). On a Mac, zig build -Dtarget=x86_64-linux-gnu or
-Dtarget=x86_64-windows-gnu makes a Linux or Windows binary.
| macOS | iOS | Linux | Windows | |
|---|---|---|---|---|
| windows, input, timers | AppKit | UIKit: screens over each other, touch, keyboards | GTK 4, else GTK 3 (loaded with dlopen) |
Win32 |
| text | CoreText | CoreText | nanoui's TrueType reader (src/ui/tt/) |
the same |
| rendering | Metal | Metal | OpenGL 3.3 in a GtkGLArea; CPU (src/ui/raster.zig) without one |
CPU, shown by Direct3D 11 (a DXGI flip swap chain; GDI without it) |
| icons | SF Symbols | SF Symbols | line icons drawn by nanoui (src/ui/icons.zig) |
the same |
| pages (webview) | WKWebView | not yet | not yet | not yet |
| menus | the menu bar | a keyboard's shortcuts | accelerators | accelerators |
The CPU renderer matches the Metal one pixel for pixel, and a test compares
them; another compares the OpenGL renderer with the CPU one, in a context
without a window. NANOUI_RENDERER=cpu draws on the CPU where OpenGL is. 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 3 and 4, fonts and a virtual display).
On Linux nanoui loads GTK 4 when it's installed and GTK 3 otherwise;
NANOUI_GTK=3 asks for GTK 3. Both work the same: GTK 4's event
controllers, draw function, clipboard and dialogs stand in for GTK 3's
event signals and waiting calls (src/linux/surface_gtk4.zig). On Windows,
frames reach the screen through a Direct3D 11 device (hardware, else WARP)
and a flip-sequential swap chain, so resizing stays smooth and content
stays anchored (src/windows/d3d.zig).
Native UI runs on iOS with the Mac's renderer and text, Metal and
CoreText; src/ios/ puts them on UIKit. The first window fills the
screen, and windows shown after it cover it until they close or hide; a
window over another needs its own way to close (no swipe down). A finger
is the pointer: it presses and drags, and scrolls what it moves up or
down over, flinging as iOS's scroll views do. A hardware keyboard's
keys and shortcuts work as on the Mac, and a focused text field brings
up the software keyboard. Views are laid out inside the safe area,
clear of the status bar, the home indicator and the keyboard; a window
made with .full_bleed = true spans the screen and keeps its content
clear itself with ui.div(c).safeArea().
zig build $(sh tools/ios/zig-lib.sh) -Dtarget=aarch64-ios-simulator # zig-out/ios/Counter.app, Gallery.app
zig build $(sh tools/ios/zig-lib.sh) run-gallery -Dtarget=aarch64-ios-simulator # in the booted SimulatorYou need Xcode for the iOS SDK. Zig 0.17.0's std can't build programs for
iOS as it ships: it takes iOS for a system that can replace its process
image (std.process.can_replace). tools/ios/zig-lib.sh makes Zig's lib
folder again out of links to it, with that one line fixed, and prints the
--zig-lib flag that uses it (nothing, once Zig is fixed), which goes
first after zig build.
-Dtarget=aarch64-ios builds for devices: sign the app with your team
(Xcode, or codesign and a provisioning profile) and install it with
xcrun devicectl. An app bundles itself in its own build.zig:
const bundle = @import("nanoui").ios.app(b, exe, .{ .name = "Notes", .id = "com.example.notes" });Zig 0.17 makes iOS 18 the lowest an app runs on unless the target names
one, and the app's Info.plist follows it. nanoui runs on iOS 15 and
later: -Dtarget=aarch64-ios.15.0 (or aarch64-ios.15.0-simulator)
reaches those devices.
Not on iOS yet: pages, input methods that compose text (Japanese, Chinese), keys repeating while held, and more than one window on an iPad.
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. |
nanostudio: a screen recorder and editor. It records a display or a window with its sound, then zooms where you clicked. You can trim, speed up and dress the take, and export it. Every frame, preview and export alike, is a nanoui view. |
Download them for macOS: nanoterm · nanomon · nanostudio.
They 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.
A counter app each, from empty build caches, on the same M3 Ultra
(sh tools/buildperf.sh measures it again: mygo's
examples/counter-native with Go 1.27.1, nanoui's from nui start with
Zig 0.17.0; network fetches aren't timed):
| mygo | nanoui | |
|---|---|---|
| cold build | 4.4 s | 6.3 s (a second app, Zig's global cache warm: 4.8 s) |
| rebuild, nothing changed | 0.28 s | 0.12 s |
| rebuild after a one-line edit | 0.55 s | 2.2 s |
| build cache on disk | 182 MB | 97 MB |
| program | 14 MB | 4 MB Debug, 0.7 MB ReleaseFast (11 s), 0.4 MB ReleaseSmall (6.7 s) |
Zig compiles the app and nanoui as one unit, so an edit rebuilds the whole of it where Go rebuilds one package: that's where mygo is faster. Cold builds are close, nothing-changed rebuilds faster, and the disk a project costs is about half.
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 releaseTo 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, notifications, channels or plugins yet,
and bundling only for iOS. 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), GPUI (Apache 2.0), gpui-motion (MIT) and GPUI Kit (Apache 2.0) keeps its own notices; see third-party notices.
nanoui stands on the shoulders of these projects:
- mygo by egoist:
nanoui began as a port of its core to Zig, and keeps its architecture. That
means:
- the Objective-C runtime called directly, with runtime classes for delegates;
- a batched outbox with one evaluation per main-loop turn;
- events held until the page is ready;
- a generated, typed client;
- native UI drawn by the framework;
- Liquid Glass drawn by its own renderers, ported from mygo's glass plugin.
- GPUI by
Zed Industries. From GPUI, nanoui takes:
- elements styled by method chains;
- the virtualized
uniform_list; - layouts cached across frames, with glyphs rasterized once into an atlas;
- Gaussian shadows;
- its general idea of a fast, GPU-drawn native UI.
- gpui-motion by
1keep:
ui.motionfollows its model and API. That covers interruptible targets, presence, keyframes, stagger, layout animation, its exact springs and its easing curves. - GPUI Kit by
Longbridge:
nanoui.kitfollows its components, their variants and their names.
Built in Zig 0.17 · AGENTS.md · benchmarks · MIT · third-party notices







