---
title: "Quick start | zenit Zig UI Docs"
description: "Create a native macOS app that lives outside the zenit repo and builds and runs on its own."
url: https://ui.surf/zenit/docs/guide/getting-started
language: en
alternate_zh: https://ui.surf/zenit/zh/docs/guide/getting-started.md
alternate_es: https://ui.surf/zenit/es/docs/guide/getting-started.md
alternate_ja: https://ui.surf/zenit/ja/docs/guide/getting-started.md
alternate_ko: https://ui.surf/zenit/ko/docs/guide/getting-started.md
alternate_fr: https://ui.surf/zenit/fr/docs/guide/getting-started.md
alternate_de: https://ui.surf/zenit/de/docs/guide/getting-started.md
project: zenit v0.1.0-alpha (Zig 0.15.2, macOS)
source: https://github.com/version-next/zenit
---

# Quick start

Create a native macOS app that lives outside the zenit repo and builds and runs on its own.

Building with Claude Code or another agent? Install the zenit UI dev Skill first.

It packs this manual, the component decision table and real-project pitfalls into instructions your agent loads on demand, so the code it writes follows zenit conventions.

[Download the Skill.zip · 54 KB](https://ui.surf/zenit/downloads/zenit-ui-dev.zip)[Install guide](https://ui.surf/zenit/docs/guide/ai-skill)

## Prerequisites

This release targets macOS, with Apple Silicon continuously verified. Make sure you have Zig 0.15.2 and the Xcode Command Line Tools; Bun is only needed for end-to-end tests.

```sh
zig version
xcode-select -p
```

> WARNING
> 
> **Versions must match.** The template declares `minimum_zig_version = "0.15.2"` in `build.zig.zon`; other versions can fail on build APIs or package fingerprints in confusing ways.

## Create a project

Start from `templates/minimal-app` in the repo. It is a complete downstream project:

`templates/minimal-app`

```
minimal-app/
├── README.md
├── build.zig          # zenit.attach() + run / app steps
├── build.zig.zon      # declares the zenit dependency
├── e2e/
│   └── record-demo.ts # drives and records the app through the Harness
└── src/
    └── main.zig       # counter-button app
```

1.  **Copy the template**
    
    Copy templates/minimal-app to a new directory outside the zenit repo.
    
2.  **Fix the dependency path**
    
    The template defaults to `.path = "../../"`, which only resolves inside the repo. After copying, point it at your zenit checkout relative to build.zig.zon, or pin a published revision with `zig fetch --save=zenit <url>`.
    
3.  **Generate a fingerprint**
    
    Don’t keep the template’s .fingerprint: two projects sharing it collide in Zig’s package cache. Delete the line, run zig build once, and paste back the value from the error. Rename .name = .myapp while you’re there.
    

```sh
git clone https://github.com/version-next/zenit.git
cp -R zenit/templates/minimal-app ./myapp
cd myapp   # then set .zenit = .{ .path = "../zenit" } in build.zig.zon
zig build
```

## Wire up zenit

`build.zig.zon` declares the dependency; `build.zig` gets zenit’s own build API through `@import("zenit")`.

`build.zig.zon`

```zig
.{
    .name = .myapp,
    .version = "0.1.0",
    .fingerprint = 0x8798022a7f8e220e, // replace: delete this line, run zig build once
    .minimum_zig_version = "0.15.2",

    .dependencies = .{
        // Relative to this file. Absolute paths are rejected by Zig 0.15.2.
        .zenit = .{ .path = "../zenit" },
    },

    .paths = .{
        "README.md",
        "build.zig",
        "build.zig.zon",
        "e2e",
        "src",
    },
}
```

Here is the template’s build.zig verbatim. It’s more than one attach call: it forwards the test switches, installs the Harness client, and defines the run step and .app packaging:

`build.zig`

```zig
const std = @import("std");
const zenit = @import("zenit");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});
    const test_mode = b.option(bool, "test-mode", "Enable the Zenit automation harness") orelse false;
    const e2e_port = b.option(u16, "e2e-port", "Zenit Harness RPC directory suffix") orelse 19816;

    const zenit_dep = b.dependency("zenit", .{
        .target = target,
        .optimize = optimize,
        // Dependency build options are isolated in Zig. Forward these
        // explicitly so `zig build -Dtest-mode=true` reaches Zenit.
        .@"test-mode" = test_mode,
        .@"e2e-port" = e2e_port,
    });

    const exe = b.addExecutable(.{
        .name = "myapp",
        .root_module = b.createModule(.{
            .root_source_file = b.path("src/main.zig"),
            .target = target,
            .optimize = optimize,
        }),
    });

    zenit.attach(zenit_dep, exe);
    zenit.installHarnessClient(b, zenit_dep);

    b.installArtifact(exe);

    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());
    const run_step = b.step("run", "Run the app");
    run_step.dependOn(&run_cmd.step);

    // Optional: `zig build app` packages a double-clickable .app bundle.
    if (target.result.os.tag == .macos) {
        const bundled = zenit.bundleApp(b, .{
            .exe = exe,
            .display_name = "My App",
            .bundle_id = "com.example.myapp",
            .version = "0.1.0",
            .signing = .ad_hoc,
        });
        const app_step = b.step("app", "Build the .app bundle");
        app_step.dependOn(bundled.final_step);
    }
}
```

| Call | What it does |
| --- | --- |
| `zenit.attach(zenit_dep, exe)` | Adds the ui and zenit\_app module imports, compiles the five macOS ObjC bridges and links Cocoa, Metal, CoreText and the other frameworks |
| `.@"test-mode" / .@"e2e-port"` | Dependency options are isolated in Zig; forward them explicitly so zig build -Dtest-mode=true reaches zenit |
| `zenit.installHarnessClient(b, zenit_dep)` | Installs the typed Harness client to zig-out/share/zenit/harness/client.ts for e2e/record-demo.ts |
| `zenit.bundleApp(b, .{ ... })` | Produces a double-clickable zig-out/<display\_name>.app, ad-hoc signed here |

## Mount your first UI tree

`App.runWith` creates the root Scope, calls your mount function once and takes over the event loop. The mount function must have the signature `fn (cx: *ui.Cx, scope: *ui.Scope) anyerror!*ui.Node`; your app only returns the root node.

`src/main.zig`

```zig
const std = @import("std");
const ui = @import("ui");
const App = @import("zenit_app").App;

const Counter = struct {
    n: u32 = 0,
    label: ?*ui.Node = null,
    buf: [32]u8 = undefined,

    pub fn increment(self: *Counter) void {
        self.n += 1;
        const node = self.label orelse return;
        const content = std.fmt.bufPrint(&self.buf, "Clicked {d} times", .{self.n}) catch return;
        if (node.getText()) |old| {
            var t = old;
            t.content = content;
            node.setText(t);
        }
        node.markRenderDirty();
    }
};

fn mountUi(cx: *ui.Cx, scope: *ui.Scope) anyerror!*ui.Node {
    const allocator = cx.allocator;

    const counter = try cx.bindState(Counter, .{});
    const click = cx.on(Counter, counter, Counter.increment);

    const root = try ui.box(cx, .{
        .width = .fill(),
        .height = .fill(),
        .direction = .column,
        .gap = 16,
        .padding = ui.Padding.all(40),
        .background = cx.tokens.color.bg_primary,
        .align_items = .center,
        .justify = .center,
    }, .{});

    try root.appendChild(allocator, try ui.text(cx, "Hello, zenit!", .{
        .font_size = 24,
        .font_weight = 600,
        .color = cx.tokens.color.fg_primary,
    }));

    const button = try ui.widgets.Button(.{
        .label = "Click me",
        .variant = .primary,
        .on_click = click,
    }).mount(scope, cx);
    button.meta.ownership.meta.test_id = "counter.increment";
    try root.appendChild(allocator, button);

    const label = try ui.text(cx, "Clicked 0 times", .{
        .font_size = 14,
        .color = cx.tokens.color.fg_secondary,
    });
    try root.appendChild(allocator, label);
    counter.label = label;

    return root;
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();

    const app = try App.init(gpa.allocator(), .{
        .window = .{ .width = 640, .height = 480, .title = "Hello, zenit" },
    });
    defer app.deinit();

    try app.runWith(mountUi);
}
```

`cx.bindState` hands `Counter` to the Cx, and `cx.on` turns one of its methods into the button callback; `test_id` lets E2E scripts find the button by meaning.

> NOTE
> 
> **The template updates text by hand to stay minimal.** The buffer lives inside `Counter` at a stable address, so lending it to `setText` is safe. Real apps more often keep the count in a Signal and let `ui.textFmt` update the label. See [Reactivity](https://ui.surf/zenit/docs/guide/reactivity).

## Build and run

```sh
zig build run   # build and run the unbundled executable
zig build app   # package zig-out/My App.app (macOS)
```

[Video](https://ui.surf/zenit/media/hello-button.mp4?v=b4d2b8f8a0)

The real Hello Button.app launched from build output (examples/hello\_button, the same counter button as the template): the harness clicks three times with the hand cursor and the label reads “Clicked 3 times”. Window, widget and response all come from the public API above.

The template also ships a recording script: build with `zig build -Dtest-mode=true`, start the app, then run `e2e/record-demo.ts` to get the same kind of clip. See [E2E harness](https://ui.surf/zenit/docs/advanced/e2e).

> TIP
> 
> **Next.** Once the window appears, read Project structure and the UI tree next. Don’t rush to build your own component layer: zenit already ships widgets, themes and reactive primitives.
