Knots is a cross-platform immediate-mode GUI library for Zig. You write the interface as Zig code, and the same code runs on macOS, Windows, Linux, and in the browser.
The UI engine does not depend on a window system or a graphics API. App is the
bundled window and renderer. ui.Context and render.Packet let you embed
Knots in your own window or renderer.
| Platform | Default backend | Other backends |
|---|---|---|
| macOS | WebGPU | Vulkan (MoltenVK) |
| Linux (Wayland) | Vulkan | WebGPU |
| Windows | Vulkan | WebGPU |
| WASM (freestanding) | WebGPU | — |
Select a backend with the gpu_backend dependency option. Read
GPU backends.
- Linux windows use Wayland only. There is no X11 support.
- Text uses one glyph for each Unicode codepoint. There is no complex shaping, bidirectional text, ligatures, kerning, font fallback, or IME composition.
- Accessibility (AccessKit) is available on macOS, Windows, and Linux. It is not available in the browser.
- Zig. The minimum version is in build.zig.zon. Knots follows the Zig master branch.
- On Linux: the Wayland development packages
wayland-client,wayland-cursor,wayland-protocols,wayland-scanner,pkg-config, andxkbcommon. - For the Vulkan backend: a Vulkan 1.3 driver with dynamic rendering.
- For the browser: a browser with WebGPU.
| Dependency | Used for | Linking |
|---|---|---|
| wgpu | WebGPU backend (native) | Static, shared on Windows MSVC (ship wgpu_native.dll) |
| vulkan, vulkan_headers | Vulkan backend | Vulkan loader at runtime |
accesskit (lib/accesskit) |
Native accessibility | Static, patched so it links next to wgpu-native |
| zig_objc | macOS windowing | - |
| wayland | Linux windowing | System libwayland |
| TrueType | Text | - |
js_bridge (lib/js-bridge) |
Browser host | - |
| celer | zig build dev web server (dev only) |
- |
zig fetch --save git+https://github.com/knots-ui/knots.gitAdd the knots and ui modules to your executable:
const knots_dep = b.dependency("knots", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("knots", knots_dep.module("knots"));
exe.root_module.addImport("ui", knots_dep.module("ui"));Build the UI in a frame callback:
const std = @import("std");
const knots = @import("knots");
const ui = @import("ui");
pub fn main(init: std.process.Init) !void {
var app = try knots.App.init(init.io, init.gpa, .{
.window = .{ .width = 1280, .height = 720, .title = "Knots" },
});
defer app.deinit();
try app.start(frame);
}
fn frame(_: *knots.View, context: *ui.Frame) !void {
const size = context.input().logical_extent;
try context.e(.{
ui.component.Rect{ .key = .src(@src()), .style = &.{
.width = .fixed(@floatFromInt(size.width)),
.height = .fixed(@floatFromInt(size.height)),
.padding = .all(16),
.background = .bg,
} },
.{
ui.component.Text{ .key = .src(@src()), .content = "Hello from Knots" },
},
});
}View contains the owning app, the viewport id, and a snapshot of the
renderer status. To get your own state, put knots.App in a field of your
struct and use @fieldParentPtr("app", view.app). Keep the App at one
address until start returns.
Add a dev step to your build.zig:
const dev = b.step("dev", "Rebuild and restart the app on every save");
knots.addDevWatcher(b, knots_dep, dev, exe, .{});Then run:
zig build dev # native app
zig build dev -Dtarget=wasm32-freestanding # browser app, at http://localhost:8000Each time you save a file, the app rebuilds. A native app restarts, and a browser page reloads. If the build fails, the error shows in the terminal and the old app keeps running.
The app is the same one zig build installs. You do not need to change your
code for dev.
On macOS, each rebuild compiles the whole app, because Zig 0.17 cannot compile macOS apps incrementally yet. Other targets compile only what changed.
See the playground.
Import ui, input, and render. Add renderer when you use the bundled GPU
backend:
app_module.addImport("ui", knots_dep.module("ui"));
app_module.addImport("input", knots_dep.module("input"));
app_module.addImport("render", knots_dep.module("render"));
app_module.addImport("renderer", knots_dep.module("renderer"));var context = try ui.Context.init(allocator, .{});
defer context.deinit();
var frame = try context.beginFrame(host_input);
defer frame.deinit();
try buildUi(&frame);
const output = try context.endFrame(&frame);
window.setCursorShape(output.cursor_shape);
if (output.clipboard_write) |value| {
_ = try window.setClipboardText(allocator, value);
}
var submission = try gpu_frame.begin();
const prepared = try painter.prepare(&output.packet, &.{
.width = target_width,
.height = target_height,
.content_scale = host_input.content_scale,
.upload_slot = submission.upload_slot,
.frame_context = submission,
.linear_target = false,
});
try painter.encode(&prepared, &host_pass);Use Renderer.render when Knots owns the surface. Use Painter.prepare and
Painter.encode when the host owns render passes, submission, or presentation.
render.Packet does not depend on a graphics API, but its geometry, glyph,
clip, and shader conventions are the Knots protocol. A renderer for another API
reads the packet directly. Painter uses the bundled backend types. See
examples/embedded and
Embedding.
- Packet data, input slices, image bytes, and callback data are borrowed. Use them before the next frame, or copy them for async work.
- The host must keep textures and callback resources alive until the GPU completes the work.
- Complete the previous work for an upload slot before you use it again in
Painter.prepare. CallPainter.destroyAfterWaitonly after all GPU work is complete. Frame.deinitaborts a frame that did not end. It is safe on copied handles.- Apply cursor and clipboard effects one time. The host decides when to redraw or close a window.
- Custom backends must validate packet extensions and reject unsupported commands.
The bundled renderer synchronizes glyph atlas uploads and releases replaced resources by upload slot. Its cache and packet data are bounded. You do not need to acknowledge glyph uploads.
The browser build uses the same App and frame callback. Install the web host
with knots.installWeb:
const knots = @import("knots");
const knots_dep = b.dependency("knots", .{
.target = target,
.optimize = optimize,
.web_threads = true,
});
const exe_mod = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "knots", .module = knots_dep.module("knots") },
.{ .name = "ui", .module = knots_dep.module("ui") },
},
});
const exe = b.addExecutable(.{ .name = "app", .root_module = exe_mod });
exe.entry = .disabled;
knots.installWeb(b, knots_dep, exe_mod, exe, .{});Build with:
zig build -Dtarget=wasm32-freestandingA browser build exports a start function instead of main. Read
Compile & distribute for the
entry point and the HTML page.
Threaded builds need a cross-origin isolated page:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Set .web_threads = false for a build without shared memory.
- examples/playground: a component catalog, with hot reloading.
- examples/embedded: a host that owns its render passes.
- examples/triangle: drawing with the
Canvascomponent. - examples/benchmark: a stress test with Tracy zones.