a reactive ui framework that runs in the browser, written in zig, compiled to webassembly, rendered with webgpu.
you write zig code. it compiles to a small wasm binary. the browser loads it and renders everything through webgpu. no virtual dom. no diffing. no garbage collector. just raw speed.
how butterflies works zig source signals, widgets, math style, layout, effects wasm binary ~2mb release small zero alloc runtime browser webgpu canvas 60fps rendering the rendering pipeline you call app.begin() layout engine computes rects render tree flattened cmds gpu renderer sdf quads webgpu single draw callthe framework gives you a way to build user interfaces using zig. you describe your ui as a tree of widgets. each frame, the framework lays out the tree, figures out where everything goes, and sends it to the gpu in one batch.
here is what the kitchen sink example looks like when rendered:
butterflies kitchen sink every widget and pattern in one place home settings about cpu 42.7% memory 1.2gb latency 0.8ms fps 120 counter demo current value: 42 minus one plus one reset progress bars brightness: 75%you need zig 0.13 and bun. that is it.
# install bun if you do not have it
curl -fsSL https://bun.sh/install | bash
# clone and enter the repo
git clone https://github.com/renderffx/road-track.git
cd road-track/butterflies
# install npm deps
bun install
# build the wasm binary
zig build wasm-browser
# start the dev server
bun run dev
# open http://localhost:8080the first time you run zig build wasm-browser it will take a few seconds. after that it is fast because zig caches everything.
here is the simplest possible app. it shows a counter with plus and minus buttons.
const fwk = @import("root.zig");
const App = fwk.api.App;
const Color = fwk.style_mod.Color;
const Length = fwk.style_mod.Length;
const Rect = fwk.style_mod.Rect;
var app: App = undefined;
var count: i32 = 0;
fn render() void {
app.begin(.{
.width = Length.px(400),
.height = Length.px(300),
.background = Color.rgb(18, 18, 24),
.direction = .column,
.padding = Rect.all(24),
});
app.text("count: {d}", .{count});
app.spacer(0, 16);
app.row(.{});
if (app.button(.{}, "minus")) {
if (count > 0) count -= 1;
}
app.spacer(8, 0);
if (app.button(.{}, "plus")) {
count +|= 1;
}
app.end();
app.end();
}that is it. you call app.begin() to open a container, add stuff inside it, then call app.end() to close it. buttons return true when clicked. text uses zig format strings. everything else is handled by the framework.
here is every widget you can use. each one works the same way: call it inside a begin/end block.
displays text. uses zig format strings so you can embed values.
app.text("hello {s}", .{"world"});
app.text("score: {d}", .{42});
app.text("pi is {d:.2}", .{3.14159});a clickable button. returns true when the user clicks it.
if (app.button(.{}, "click me")) {
// do something when clicked
}you can style the button with background color, padding, border radius:
if (app.button(.{
.background = Color.rgb(180, 40, 40),
.border_radius = 8,
}, "danger")) {
// handle danger action
}a horizontal slider that modifies a value. you give it a pointer to a float and it updates it when the user drags.
var volume: f32 = 50;
app.slider(.{}, &volume, 0, 100, 300);
app.text("volume: {d:.0}%", .{volume});the last number (300) is the pixel width of the slider track.
a switch that flips a boolean. returns true when toggled.
var dark_mode: bool = true;
if (app.toggle(.{}, &dark_mode)) {
// state changed
}
app.text(" dark mode", .{});shows a fraction as a filled bar.
app.progressBar(.{}, 0.65, 400); // 65% full, 400px widecycles through a list of options. each click moves to the next option.
var selected: u32 = 0;
const options = [_][]const u8{ "red", "green", "blue" };
app.dropdown(.{}, &selected, &options);a text input field. you pass it a buffer and a length pointer.
var buf: [128]u8 = undefined;
var len: u16 = 0;
app.input(.{}, &buf, &len);a simple colored rectangle. useful for swatches, dividers, indicators.
app.coloredBox(Color.rgb(255, 0, 0), 40, 40); // red, 40x40invisible spacing between elements.
app.spacer(0, 16); // 0 wide, 16 tall (vertical gap)
app.spacer(8, 0); // 8 wide, 0 tall (horizontal gap)layout containers. row puts children side by side. column stacks them.
app.row(.{});
app.button(.{}, "left");
app.button(.{}, "right");
app.end();
app.column(.{});
app.text("top", .{});
app.text("bottom", .{});
app.end();a scrollable container.
app.scroll(.{
.width = Length.px(300),
.height = Length.px(200),
});
// lots of content here
app.end();every widget accepts a StyleProps struct. you only need to set what you want. everything else uses defaults.
app.begin(.{
.width = Length.px(200),
.height = Length.px(100),
.background = Color.rgb(25, 25, 35),
.border_radius = 10,
.padding = Rect.all(12),
.direction = .column,
});here is what each property does:
| property | what it does | example |
|---|---|---|
| width | how wide | Length.px(200) or Length.pct(50) |
| height | how tall | Length.px(100) or Length.pct(100) |
| background | fill color | Color.rgb(25, 25, 35) |
| color | text color | Color.WHITE |
| border_radius | rounded corners | border_radius = 10 |
| padding | inner spacing | Rect.all(12) or Rect{ .top = 8, .left = 4 } |
| margin | outer spacing | Rect.all(8) |
| direction | row or column | .direction = .row |
| opacity | transparency | opacity = 0.5 |
| z_index | layer order | z_index = 10 |
you can make colors three ways:
Color.rgb(255, 128, 0) // from red, green, blue
Color.rgba(255, 128, 0, 128) // with alpha
Color.fromHex(0xFF8000) // from hex codeLength.px(200) // 200 pixels
Length.pct(50) // 50% of parent
Length.auto() // fit content
Length.fill() // fill remaining spacehere is exactly what happens every frame, from your zig code to pixels on screen:
what happens each frame step 1: describe you call begin, text, button, slider, end this builds a tree step 2: layout engine computes x, y, width, height for every node step 3: flatten tree becomes a flat list of render commands (quads) step 4: draw webgpu renders all quads in a single draw call inside the wasm binary signal graph dirty tracking ecs registry entity slots style store 2048 styles layout engine flexbox subset math simd memory: slab allocator (entities) + arena allocator (per frame) + string interning (fNV-1a)the file src/examples_kitchen_sink.zig shows every feature in one app. it has:
- a tab bar that switches between three views
- metric cards showing live data
- a counter with increment, decrement, and reset
- progress bars that respond to a slider
- a dark mode toggle
- a dropdown selector
- a text input field
- color swatches
to run it, you point the main entry to renderApp() instead of the default example. or just read the source to see how each widget is used.
the test suite in that file verifies that every tab renders, every widget works, and the counter behaves correctly.
zig build test
this runs all the zig unit tests including the kitchen sink tests.
butterflies does zero heap allocations at runtime. here is how:
memory layout slab allocator fixed size blocks entities, styles, layout nodes alloc: O(1) pop from free list free: O(1) push to free list arena allocator bump pointer per frame scratch text buffers, temp alloc: advance pointer free: reset to start string interning fNV-1a hash deduplicates strings one copy per unique str lookup: O(1) hash map store: grow-only poolthe slab allocator is the workhorse. it pre-allocates a fixed number of blocks (2048 styles, 1024 entities, 4096 effects). when you need a new entity, it pops from a free list. when you are done, it pushes back. no malloc, no free, no gc.
the arena allocator is used for temporary data that lives only one frame. text buffers, format strings, intermediate layout results. at the end of the frame, the arena resets to the beginning. everything is gone. ready for next frame.
string interning means if you use the same string in 100 places, it only exists once in memory. the fNV-1a hash gives you O(1) lookups.
butterflies has a reactive signal graph. when a signal changes, anything that depends on it gets re-evaluated.
// signals are created through the wasm api
// nz_create_signal(0.0) -> returns an id
// nz_set_signal(id, new_value)
// nz_get_signal(id) -> reads the value
// the framework tracks which signals each effect reads
// when you call nz_set_signal, all dependent effects re-runthe signal system supports:
- automatic dependency tracking (reads during effect execution register dependencies)
- batch updates (multiple signal changes in one tick)
- version numbers (check if a signal changed since last read)
- overflow sentinel (0xFFFFFFFF means invalid/unset)
there are three levels of tests:
these test the framework internals. signals, memory, layout, math. they run fast and do not need a browser.
zig build testthese use playwright to open a real browser, load the wasm, and verify it works.
bun run test:e2ethis runs everything: zig tests, browser tests, security checks, performance benchmarks, accessibility audits, visual regression, and bdd step definitions.
bun run nuclearthat one command builds, tests, measures coverage, runs mutation testing, and gives you a full quality report.
butterflies/
src/
api.zig the app struct with all widgets
wasm_entry.zig wasm exports (nz_init, nz_tick, etc)
core/
signal.zig reactive signals
effect.zig effect system (4096 slots)
batch.zig batched updates
ecs.zig entity component system
slot_map.zig generational handles
benchmark.zig timing and stats
profiler.zig scoped profiling
frame_scheduler.zig fixed timestep
ui/
style.zig colors, lengths, styles (2048 slots)
layout.zig flexbox layout engine
component.zig element store
render_tree.zig tree flattening
event_dispatch.zig input events
renderer/
webgpu.zig gpu device management
gpu_render.zig sdf quad rendering
webgpu_api.zig webgpu bindings
memory/
slab.zig slab allocator
arena.zig arena allocator
string_intern.zig fNV-1a string dedup
math/
simd.zig vec2, vec3, vec4, mat4
threading/
lockfree_queue.zig spsc lock-free queue
worker_pool.zig thread pool
web/
index.html entry page (csp hardened)
framework.js browser runtime
shaders.wgsl webgpu shaders
tests/
e2e.spec.js playwright e2e
security/ security tests
performance/ perf regression
accessibility/ a11y tests
visual/ screenshot tests
browser/ cdp browser tests
utils/ test helpers
scripts/
quality-gate.js full quality pipeline
coverage.js coverage analysis
mutation-test.js mutation testing
qa-checklist.js automated qa checks
# builds with size budget (fails if > 2mb)
bun run build:prod:check
# the output is at:
# zig-out/wasm/butterflies-browser.wasmfor deployment, copy the web/ folder and the wasm binary to any static host. there is an nginx config included if you want to serve it yourself.
# quick test with nginx
cp nginx.conf /etc/nginx/sites-available/butterflies.conf
ln -s /etc/nginx/sites-available/butterflies.conf /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginxyou need a browser with webgpu. that means chrome 113+, edge 113+, or opera 99+. firefox and safari do not support webgpu yet.
the framework itself (zig to wasm) works in any modern browser. only the rendering part needs webgpu.
the framework is designed for speed. here are the targets:
| metric | target |
|---|---|
| average frame time | less than 1ms |
| p99 frame time | less than 5ms |
| wasm binary size | less than 2mb |
| memory per frame | zero allocations |
| render calls per frame | 1 (batched) |
| layout computation | sub-millisecond for 1000 nodes |
the renderer uses signed distance fields (sdf) to draw rounded rectangles. instead of rasterizing pixels, the gpu computes whether each pixel is inside or outside the shape. this means:
- one draw call for the entire ui
- resolution independent (looks sharp at any zoom)
- rounded corners for free (just a math formula)
- no texture atlas needed
the vertex shader receives position, uv, color, and corner radius for each quad. the fragment shader computes the sdf and outputs the pixel color. alpha blending handles overlap.
sdf rendering of a rounded box rounded rect border_radius = 12 sharp rect border_radius = 0 the gpu computes distance to edge per pixel, no rasterization neededmit. do whatever you want with it.