Skip to content

Tutorial

Build a todo app #

This tutorial starts with an empty directory and ends with a todo app. The app runs as a native program and in the browser. Each step adds one feature and explains it.

Before you start, do the requirements step of Getting started. The tutorial takes approximately 30 minutes.

Step 1

Create the project #

Make a directory and let Zig make a project in it:

mkdir todo
cd todo
zig init

zig init makes build.zig, build.zig.zon, src/main.zig, and src/root.zig. This app does not use a library module, so delete src/root.zig:

rm src/root.zig

Add Knots as a dependency:

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

Step 2

Configure the build #

Replace all of build.zig with this code. It gets the Knots dependency, imports two modules, and adds a run step.

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

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

    const exe = b.addExecutable(.{
        .name = "todo",
        .root_module = 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") },
            },
        }),
    });
    b.installArtifact(exe);

    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());
    run_cmd.addPassthruArgs();
    b.step("run", "Run the app").dependOn(&run_cmd.step);
}
  • knots contains App, which owns the window.
  • ui contains the components, styles, and the frame type.

Step 3

Open a window #

Replace all of src/main.zig with this code:

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 = 720, .height = 640, .title = "Todo" },
    });
    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)),
            .background = .bg,
        },
    });
}

Run the app:

zig build run

An empty window opens. The code does these steps:

  • App.init opens the window and starts the GPU renderer.
  • App.start runs the event loop. It calls frame when the window needs a new frame.
  • context.input().logical_extent is the window size in logical pixels.
  • Rect is the basic box. Its style sets its size and its background color. .bg is a theme color.

Step 4

Hold the application state #

The frame function has no state of its own. Put the app data in a struct. Make the struct the file itself, and put knots.App in a field. The frame function can then find the struct from view.app.

Remove the main and frame functions from step 3. Add this code below the imports:

const Todo = struct {
    id: usize,
    title: []u8,
    done: bool = false,
};

app: knots.App,
/// Owns every title for the life of the app.
arena: std.heap.ArenaAllocator,
todos: std.ArrayList(Todo) = .empty,
/// The text in the input field. TextInput grows it with the UI allocator.
draft: std.ArrayList(u8) = .empty,
next_id: usize = 0,

const Self = @This();

pub fn init(io: std.Io, allocator: std.mem.Allocator) !Self {
    var self: Self = .{
        .app = try knots.App.init(io, allocator, .{
            .window = .{ .width = 720, .height = 640, .title = "Todo" },
            .ui = .{ .theme = ui.Theme.dark },
        }),
        .arena = .init(allocator),
    };
    errdefer self.deinit();
    try self.add("Read the Knots docs");
    return self;
}

pub fn deinit(self: *Self) void {
    self.draft.deinit(self.arena.child_allocator);
    self.arena.deinit();
    self.app.deinit();
}

fn add(self: *Self, title: []const u8) !void {
    const trimmed = std.mem.trim(u8, title, " \t\r\n");
    if (trimmed.len == 0) return;
    const arena = self.arena.allocator();
    try self.todos.append(arena, .{ .id = self.next_id, .title = try arena.dupe(u8, trimmed) });
    self.next_id += 1;
}

pub fn main(process: std.process.Init) !void {
    var self = try Self.init(process.io, process.gpa);
    defer self.deinit();
    try self.app.start(frame);
}

pub fn frame(view: *knots.View, context: *ui.Frame) !void {
    const self: *Self = @alignCast(@fieldParentPtr("app", view.app));
    _ = self;
    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)),
        .background = .bg,
    } });
}
  • .ui = .{ .theme = ui.Theme.dark } selects the dark theme. The default theme is ui.Theme.light.
  • @fieldParentPtr("app", view.app) gets the Self that contains the app. For this reason, self must not move while start runs.
  • Each todo has an id that does not change. Step 8 uses it for keys.

Step 5

Lay out the page #

Put a centered column in the window. The column fills the height, and its width stops at 600 pixels. Add these declarations below the imports:

const Rect = ui.component.Rect;
const Text = ui.component.Text;
const TextInput = ui.component.TextInput;
const Button = ui.component.Button;
const Checkbox = ui.component.Checkbox;
const ProgressBar = ui.component.ProgressBar;

Then replace the body of frame:

pub fn frame(view: *knots.View, context: *ui.Frame) !void {
    const self: *Self = @alignCast(@fieldParentPtr("app", view.app));
    const size = context.input().logical_extent;
    // A max on the cross axis of a column is not applied, so calculate the width.
    const column_width = @min(@as(f32, @floatFromInt(size.width)) - 48, 600);

    try context.e(.{
        Rect{ .key = .src(@src()), .style = &.{
            .width = .fixed(@floatFromInt(size.width)),
            .height = .fixed(@floatFromInt(size.height)),
            .padding = .xy(24, 32),
            .direction = .column,
            .@"align" = .center,
            .background = .bg,
        } },
        .{
            Rect{ .key = .src(@src()), .style = &.{
                .width = .fixed(column_width),
                .height = .grow(),
                .direction = .column,
                .gap = 16,
            } },
            .{
                Header{ .self = self },
                Entry{ .self = self },
                Rect{ .key = .src(@src()), .style = &card },
                .{List{ .self = self }},
            },
        },
    });
}

A component followed by a tuple is a parent. The tuple holds its children. The outer Rect is a column that centers its children on the cross axis. The inner Rect gets a fixed width: the window width less the padding, to a maximum of 600 pixels. Knots does not apply max to a .grow() width in a column, so the code calculates the width.

Header, Entry, List, and card do not exist yet. The next steps add them. The Layout engine page explains sizes, direction, and alignment.

Step 6

A custom component is a struct with a render function. Give it the data that it needs as fields. Add this code:

const Header = struct {
    self: *Self,

    pub fn render(header: *const Header, context: *ui.Frame) anyerror!void {
        const todos = header.self.todos.items;
        var done: usize = 0;
        for (todos) |todo| done += @intFromBool(todo.done);

        try context.e(.{
            Rect{ .key = .src(@src()), .style = &.{
                .width = .grow(),
                .direction = .row,
                .@"align" = .center,
                .justify = .space_between,
            } },
            .{
                Text{ .key = .src(@src()), .content = "Todos", .style = &.{ .font_size = .xl }, .selectable = false },
                Text{
                    .key = .src(@src()),
                    .content = try std.fmt.allocPrint(context.arena(), "{d} of {d} done", .{ done, todos.len }),
                    .style = &.{ .font_size = .sm, .foreground = .dimmed },
                    .selectable = false,
                },
            },
        });
    }
};
  • context.arena() is an allocator that Knots clears after each frame. Use it for text that you make during the frame.
  • .justify = .space_between puts the title at the left and the count at the right.
  • .selectable = false stops the user from selecting the text.

Step 7

Add new todos #

The entry row has a text field and a button. The user adds a todo with the button or with the Enter key. Add this code:

const input_key: ui.Key = .str("todo.input");

const Entry = struct {
    self: *Self,

    pub fn render(entry: *const Entry, context: *ui.Frame) anyerror!void {
        const row: Rect = .{ .key = .src(@src()), .style = &.{
            .width = .grow(),
            .direction = .row,
            .gap = 8,
            .@"align" = .center,
        } };
        _ = try row.open(context);
        defer row.close(context) catch {};

        const enter = context.ui().focused(input_key.hash()) and
            context.ui().input.keyPressed(.enter);
        try context.e(TextInput{
            .key = input_key,
            .buf = &entry.self.draft,
            .placeholder = "What needs doing?",
            .style = &.{ .width = .grow() },
        });
        const clicked = (try context.interact(Button{
            .key = .src(@src()),
            .label = "Add",
            .style = &.{ .padding = .xy(16, 0) },
        })).clicked;

        if (enter or clicked) {
            try entry.self.add(entry.self.draft.items);
            entry.self.draft.clearRetainingCapacity();
            context.requestRedraw();
        }
    }
};
  • open and close are a second way to make a parent. Use them when the children need statements, as here.
  • TextInput edits draft directly. It uses the allocator of the UI, which is the app allocator.
  • context.interact emits a component and returns its response. A Button response has clicked and hovered.
  • The input field has a fixed key, input_key. The code uses the key to check if the field has focus.
  • context.requestRedraw() asks for one more frame. Do this after you change data, so that the change shows immediately.

Step 8

List the todos #

Each row has a checkbox and a remove button. Rows come from a loop, so .src(@src()) is not sufficient: each row would get the same key. Use ui.Key.str(...).indexed(todo.id) to make one key for each todo.

const List = struct {
    self: *Self,

    pub fn render(list: *const List, context: *ui.Frame) anyerror!void {
        var remove: ?usize = null;
        for (list.self.todos.items, 0..) |*todo, index| {
            const row: Rect = .{ .key = ui.Key.str("todo.row").indexed(todo.id), .style = &.{
                .width = .grow(),
                .height = .fixed(40),
                .padding = .xy(10, 0),
                .direction = .row,
                .@"align" = .center,
                .radius = .md,
                .hover = &.{ .background = .muted },
            } };
            _ = try row.open(context);

            const check = try context.interact(Checkbox{
                .key = ui.Key.str("todo.check").indexed(todo.id),
                .checked = &todo.done,
                .label = todo.title,
                .style = &.{ .width = .grow(), .foreground = if (todo.done) .dimmed else .text },
            });
            if (check.changed) context.requestRedraw();

            const remove_button = try context.interact(Button{
                .key = ui.Key.str("todo.remove").indexed(todo.id),
                .label = "Remove",
                .style = &ghost,
            });
            if (remove_button.clicked) remove = index;

            try row.close(context);
        }
        if (remove) |index| {
            _ = list.self.todos.orderedRemove(index);
            context.requestRedraw();
        }
    }
};
  • Use the todo id in the key, not the list index. When a todo is removed, the other rows keep their keys, so their hover and focus state stays correct.
  • Checkbox writes to todo.done through the pointer. changed is true in the frame where the value changed.
  • The code removes the todo after the loop. Do not change a list while you iterate over it.
  • .hover is a style that Knots applies when the pointer is over the row.

Step 9

Add shared styles #

The code uses two styles that do not exist yet: card and ghost. A style is a normal ui.Style value, so you can declare it once and use it in many places. Add this code:

const card: ui.Style = .{
    .width = .grow(),
    .height = .grow(),
    .direction = .column,
    .padding = .all(6),
    .gap = 2,
    .overflow = .scroll_y,
    .background = .elevated,
    .radius = .lg,
    .border_width = .all(1),
    .border_color = .toned,
};

const ghost: ui.Style = .{
    .height = .fixed(28),
    .padding = .xy(8, 0),
    .font_size = .xs,
    .background = .transparent,
    .foreground = .dimmed,
    .hover = &.{ .background = .muted, .foreground = .text, .state_layer = 0 },
};
  • .overflow = .scroll_y makes the card scroll when the list is taller than the card.
  • Colors such as .elevated and .toned are theme tokens. They change when the theme changes.
  • ghost changes the default button style. The fields that you set replace the fields of the default. The other fields stay the same.

Run the app. You can now add, complete, and remove todos.

zig build run

The Styles and themes page explains all style fields, states, and tokens.

Step 10

Show progress #

Add a progress bar and a button that removes completed todos. First, add this function to Self:

fn clearDone(self: *Self) void {
    var kept: usize = 0;
    for (self.todos.items) |todo| {
        if (todo.done) continue;
        self.todos.items[kept] = todo;
        kept += 1;
    }
    self.todos.shrinkRetainingCapacity(kept);
}

Then, in Header.render, add a ProgressBar after the title row, and add the button after context.e:

        const total: f32 = @floatFromInt(@max(todos.len, 1));
        try context.e(.{
            Rect{ ... }, // The title row from step 6.
            .{ ... },
            ProgressBar{
                .key = .src(@src()),
                .progress = @as(f32, @floatFromInt(done)) / total,
            },
        });
        if (done > 0 and (try context.interact(Button{
            .key = .src(@src()),
            .label = "Clear completed",
            .style = &ghost,
        })).clicked) {
            header.self.clearDone();
            context.requestRedraw();
        }

The button shows only when one or more todos are done. This is normal Zig control flow. In immediate mode, an if statement is enough to show or hide a component.

Step 11

Run in the browser #

The same code can compile to WebAssembly. A browser program has no main function that runs to the end. Instead, the page calls an exported start function. Three changes are necessary.

Change the build #

In build.zig, add const Knots = @import("knots"); at the top. Set .web_threads = false on the dependency, so that the page does not need special HTTP headers. Then replace everything after the exe declaration:

    const run_step = b.step("run", "Run the app");
    if (target.result.cpu.arch.isWasm() and target.result.os.tag == .freestanding) {
        exe.entry = .disabled;
        Knots.installWeb(b, knots, exe.root_module, exe, .{
            .index_html = b.path("web/index.html"),
        });
    } else {
        b.installArtifact(exe);
        const run_cmd = b.addRunArtifact(exe);
        run_cmd.step.dependOn(b.getInstallStep());
        run_cmd.addPassthruArgs();
        run_step.dependOn(&run_cmd.step);
    }

Add a browser entry point #

Make src/web.zig. It creates the app on the heap, because the app must stay alive after the start function returns.

const knots = @import("knots");
const Todo = @import("main.zig");

fn start() callconv(.{ .wasm_mvp = .{} }) i32 {
    const allocator = knots.web.allocator;
    const self = allocator.create(Todo) catch |err| return knots.web.fail(err);
    self.* = Todo.init(knots.web.io, allocator) catch |err| {
        allocator.destroy(self);
        return knots.web.fail(err);
    };
    self.app.start(Todo.frame) catch |err| {
        self.deinit();
        allocator.destroy(self);
        return knots.web.fail(err);
    };
    return 0;
}

comptime {
    @export(&start, .{ .name = "main" });
}

In src/main.zig, rename main to nativeMain. Then add this code:

pub const std_options: std.Options = if (knots.platform.is_browser_wasm)
    .{ .logFn = knots.web.logFn }
else
    .{};

pub const main = if (knots.platform.is_browser_wasm) struct {
    fn main() void {}
}.main else nativeMain;

comptime {
    if (knots.platform.is_browser_wasm) _ = @import("web.zig");
}

Also add .canvas_selector = "#canvas" to the .window options in init. In the browser, the window is this canvas element. Native builds ignore the field.

Add the page #

Make web/index.html:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Todo</title>
    <style>
      html, body { margin: 0; height: 100%; overflow: hidden; }
      canvas { display: block; width: 100vw; height: 100vh; }
    </style>
  </head>
  <body>
    <canvas id="canvas"></canvas>
    <script type="module">
      import { startKnots } from "./knots.js";
      startKnots({ wasmUrl: "./app.wasm", canvas: "#canvas" }).catch(console.error);
    </script>
  </body>
</html>

Build and serve the files:

zig build -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall
python3 -m http.server 8000 --directory zig-out/web

Open http://localhost:8000 in a browser that supports WebGPU. The zig-out/web directory contains all files that you must deploy: index.html, knots.js, js-bridge.js, and app.wasm.

The complete program #

The complete project is in the examples/tutorial directory of the website repository. The live todo app on the home page is a larger version of this program. It also follows the color scheme of the page.

Show the complete src/main.zig
const std = @import("std");
const knots = @import("knots");
const ui = @import("ui");

const Rect = ui.component.Rect;
const Text = ui.component.Text;
const TextInput = ui.component.TextInput;
const Button = ui.component.Button;
const Checkbox = ui.component.Checkbox;
const ProgressBar = ui.component.ProgressBar;

pub const std_options: std.Options = if (knots.platform.is_browser_wasm) .{ .logFn = knots.web.logFn } else .{};

const Todo = struct {
    id: usize,
    title: []u8,
    done: bool = false,
};

app: knots.App,
arena: std.heap.ArenaAllocator,
todos: std.ArrayList(Todo) = .empty,
draft: std.ArrayList(u8) = .empty,
next_id: usize = 0,

const Self = @This();

const input_key: ui.Key = .str("todo.input");

const card: ui.Style = .{
    .width = .grow(),
    .height = .grow(),
    .direction = .column,
    .padding = .all(6),
    .gap = 2,
    .overflow = .scroll_y,
    .background = .elevated,
    .radius = .lg,
    .border_width = .all(1),
    .border_color = .toned,
};

const ghost: ui.Style = .{
    .height = .fixed(28),
    .padding = .xy(8, 0),
    .font_size = .xs,
    .background = .transparent,
    .foreground = .dimmed,
    .hover = &.{ .background = .muted, .foreground = .text, .state_layer = 0 },
};

pub fn init(io: std.Io, allocator: std.mem.Allocator) !Self {
    var self: Self = .{
        .app = try knots.App.init(io, allocator, .{
            .window = .{ .width = 720, .height = 640, .title = "Todo", .canvas_selector = "#canvas" },
            .ui = .{ .theme = ui.Theme.dark },
        }),
        .arena = .init(allocator),
    };
    errdefer self.deinit();
    try self.add("Read the Knots docs");
    return self;
}

pub fn deinit(self: *Self) void {
    self.draft.deinit(self.arena.child_allocator);
    self.arena.deinit();
    self.app.deinit();
}

fn add(self: *Self, title: []const u8) !void {
    const trimmed = std.mem.trim(u8, title, " \t\r\n");
    if (trimmed.len == 0) return;
    const arena = self.arena.allocator();
    try self.todos.append(arena, .{ .id = self.next_id, .title = try arena.dupe(u8, trimmed) });
    self.next_id += 1;
}

fn clearDone(self: *Self) void {
    var kept: usize = 0;
    for (self.todos.items) |todo| {
        if (todo.done) continue;
        self.todos.items[kept] = todo;
        kept += 1;
    }
    self.todos.shrinkRetainingCapacity(kept);
}

pub fn frame(view: *knots.View, context: *ui.Frame) !void {
    const self: *Self = @alignCast(@fieldParentPtr("app", view.app));
    const size = context.input().logical_extent;
    // A max on the cross axis of a column is not applied, so calculate the width.
    const column_width = @min(@as(f32, @floatFromInt(size.width)) - 48, 600);

    try context.e(.{
        Rect{ .key = .src(@src()), .style = &.{
            .width = .fixed(@floatFromInt(size.width)),
            .height = .fixed(@floatFromInt(size.height)),
            .padding = .xy(24, 32),
            .direction = .column,
            .@"align" = .center,
            .background = .bg,
        } },
        .{
            Rect{ .key = .src(@src()), .style = &.{
                .width = .fixed(column_width),
                .height = .grow(),
                .direction = .column,
                .gap = 16,
            } },
            .{
                Header{ .self = self },
                Entry{ .self = self },
                Rect{ .key = .src(@src()), .style = &card },
                .{List{ .self = self }},
            },
        },
    });
}

const Header = struct {
    self: *Self,

    pub fn render(header: *const Header, context: *ui.Frame) anyerror!void {
        const todos = header.self.todos.items;
        var done: usize = 0;
        for (todos) |todo| done += @intFromBool(todo.done);
        const total: f32 = @floatFromInt(@max(todos.len, 1));

        try context.e(.{
            Rect{ .key = .src(@src()), .style = &.{ .width = .grow(), .direction = .row, .@"align" = .center, .justify = .space_between } },
            .{
                Text{ .key = .src(@src()), .content = "Todos", .style = &.{ .font_size = .xl }, .selectable = false },
                Text{
                    .key = .src(@src()),
                    .content = try std.fmt.allocPrint(context.arena(), "{d} of {d} done", .{ done, todos.len }),
                    .style = &.{ .font_size = .sm, .foreground = .dimmed },
                    .selectable = false,
                },
            },
            ProgressBar{ .key = .src(@src()), .progress = @as(f32, @floatFromInt(done)) / total },
        });
        if (done > 0 and (try context.interact(Button{ .key = .src(@src()), .label = "Clear completed", .style = &ghost })).clicked) {
            header.self.clearDone();
            context.requestRedraw();
        }
    }
};

const Entry = struct {
    self: *Self,

    pub fn render(entry: *const Entry, context: *ui.Frame) anyerror!void {
        const row: Rect = .{ .key = .src(@src()), .style = &.{ .width = .grow(), .direction = .row, .gap = 8, .@"align" = .center } };
        _ = try row.open(context);
        defer row.close(context) catch {};

        const enter = context.ui().focused(input_key.hash()) and context.ui().input.keyPressed(.enter);
        try context.e(TextInput{ .key = input_key, .buf = &entry.self.draft, .placeholder = "What needs doing?", .style = &.{ .width = .grow() } });
        const clicked = (try context.interact(Button{ .key = .src(@src()), .label = "Add", .style = &.{ .padding = .xy(16, 0) } })).clicked;

        if (enter or clicked) {
            try entry.self.add(entry.self.draft.items);
            entry.self.draft.clearRetainingCapacity();
            context.requestRedraw();
        }
    }
};

const List = struct {
    self: *Self,

    pub fn render(list: *const List, context: *ui.Frame) anyerror!void {
        var remove: ?usize = null;
        for (list.self.todos.items, 0..) |*todo, index| {
            const row: Rect = .{ .key = ui.Key.str("todo.row").indexed(todo.id), .style = &.{
                .width = .grow(),
                .height = .fixed(40),
                .padding = .xy(10, 0),
                .direction = .row,
                .@"align" = .center,
                .radius = .md,
                .hover = &.{ .background = .muted },
            } };
            _ = try row.open(context);

            const check = try context.interact(Checkbox{
                .key = ui.Key.str("todo.check").indexed(todo.id),
                .checked = &todo.done,
                .label = todo.title,
                .style = &.{ .width = .grow(), .foreground = if (todo.done) .dimmed else .text },
            });
            if (check.changed) context.requestRedraw();

            const remove_button = try context.interact(Button{
                .key = ui.Key.str("todo.remove").indexed(todo.id),
                .label = "Remove",
                .style = &ghost,
            });
            if (remove_button.clicked) remove = index;

            try row.close(context);
        }
        if (remove) |index| {
            _ = list.self.todos.orderedRemove(index);
            context.requestRedraw();
        }
    }
};

pub const main = if (knots.platform.is_browser_wasm) struct {
    fn main() void {}
}.main else nativeMain;

fn nativeMain(process: std.process.Init) !void {
    var self = try Self.init(process.io, process.gpa);
    defer self.deinit();
    try self.app.start(frame);
}

comptime {
    if (knots.platform.is_browser_wasm) _ = @import("web.zig");
}

Next steps #

  • Change UI code without a restart. Read Hot reloading.
  • Keep the todos after the app closes. Use normal Zig file I/O with init.io.
  • Make your own theme. Read Themes.
  • Ship the app. Read Compile & distribute.