← Back
knots-ui

knots-ui/knots

High performance cross-platform immediate-mode GUI rendering library.

View on GitHub ↗https://knotsui.com ↗
Stars
34
Forks
0
Watchers
34
Open issues
2
Contributors
2
Language
Zig
License
MIT License
Default branch
main
Created Sep 27, 2026Updated Oct 1, 2026

Star growth

Today—
This week—
This month—

Star history will appear here once this repo has been tracked for a couple of days.

README

Knots

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.

  • Documentation
  • Tutorial: build a todo app
  • Web playground

Supported platforms

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.

Known limitations

  • 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.

Requirements

  • 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, and xkbcommon.
  • For the Vulkan backend: a Vulkan 1.3 driver with dynamic rendering.
  • For the browser: a browser with WebGPU.

Install

zig fetch --save git+https://github.com/knots-ui/knots.git

Minimal app

Add the knots and ui modules to your executable:

const knots = b.dependency("knots", .{ .target = target, .optimize = optimize });

exe.root_module.addImport("knots", knots.module("knots"));
exe.root_module.addImport("ui", knots.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.

Hot reloading

Knots can compile UI modules to WebAssembly and reload them in a running native or browser host. Use Knots.HMR in build.zig and knots.Modules in the host. Read Hot reloading. The playground is a complete HMR host.

Embedding in an existing renderer

Import ui, input, and render. Add renderer when you use the bundled GPU backend:

app_module.addImport("ui", knots_dependency.module("ui"));
app_module.addImport("input", knots_dependency.module("input"));
app_module.addImport("render", knots_dependency.module("render"));
app_module.addImport("renderer", knots_dependency.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.

Ownership and lifetime

  • 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. Call Painter.destroyAfterWait only after all GPU work is complete.
  • Frame.deinit aborts 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.

Browser WASM

The browser build uses the same App and frame callback. Install the web host with Knots.installWeb:

const Knots = @import("knots");

const knots = 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.module("knots") },
        .{ .name = "ui", .module = knots.module("ui") },
    },
});
const exe = b.addExecutable(.{ .name = "app", .root_module = exe_mod });
exe.entry = .disabled;
Knots.installWeb(b, knots, exe_mod, exe, .{});

Build with:

zig build -Dtarget=wasm32-freestanding

A 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

  • examples/playground: a component catalog and HMR host.
  • examples/embedded: a host that owns its render passes.
  • examples/triangle: drawing with the Canvas component.
  • examples/benchmark: a stress test with Tracy zones.