docs/guide/components
Core concepts · Components

Components

What a zenit component promises, when to drop down a layer, and the three contracts: mounting, overlays and testing. Per-component recordings, props and result types live on the component site.

10 min read

What “component” means here

A zenit component isn’t a helper that glues colors and corner radii together. A public component typically packages node structure, persistent state, event routing, focus and keyboard semantics, accessibility properties, theme tokens, overlay lifetime, and a test boundary the harness can locate and read back. They are all exported from ui.widgets.

51Stories on the component site
6Use-case categories (plus Labs)
1:1Story to E2E test_id
ANATOMY
mount first creates a child Scope; everything it owns is released together with the parent Scope.

Choose the right layer first

NeedLayerYou still own
Standard product controlsui.widgets.*Business state, copy, callbacks and placement
Custom visuals, proven interactionui.hooks · ui.interaction · ui.select_headlessDrawing, tokens, a11y labels and composition boundaries
A new interaction patternui.Node + ui.eventsHit-testing, keyboard, focus, IME, a11y and the test contract

Catalog

Every component has its own page on the component site: a recording of the real zenit Storybook.app, props and result types generated from source, and what the recording proves. The harness locates each story by test_id, drives a virtual cursor or input, and records the Metal drawable directly.

CategoryCountExamples
Actions7Button · Checkbox · Switch · RadioGroup · Slider …
Display17Badge · Tag · Chip · Card · Alert …
Navigation4Tabs · Accordion · Menu · DropdownMenu
Overlays5Notifier · Tooltip · Popover · Modal · Sheet
The Button story sweeps the variant × size matrix, then hovers and presses Primary. Details at /components/button.

Mount, state & cleanup

Most visual components use the builder form Config.mount(scope, cx): ui.widgets.Button(props) returns a builder and mount actually builds the tree. The return value is either the root *Node or a result struct with handles such as wrapper, trigger, body, panel and state.

mount_form.zig
const EditorActions = struct {
    document: *Document,

    fn save(self: *EditorActions) void {
        self.document.save();
    }
};

const bindings = try cx.bindState(EditorActions, .{ .document = document });

const save = try ui.widgets.Button(.{
    .label = "Save",
    .variant = .primary,
    .on_click = cx.on(EditorActions, bindings, EditorActions.save),
}).mount(scope, cx);

const name = try ui.widgets.Input(.{
    .label_text = "Project name",
    .placeholder = "Untitled",
    .required = true,
    .width = 320,
}).mount(scope, cx);

try form.appendChild(cx.allocator, name);
try form.appendChild(cx.allocator, save);

A few components use the function form, taking props, scope and cx in one call: mountScrollArea, mountGrid, plus Select, ComboBox, TagsInput, NumberStepper, FileUpload and DataTable (these are aliases of mountX functions in ui.widgets, called as ui.widgets.Select(props, scope, cx)).

scroll_area.zig
// Function form: props, scope and cx in one call; returns a handle struct.
const area = try ui.widgets.mountScrollArea(.{ .height = 320 }, scope, cx);
try area.content.appendChild(cx.allocator, list);
try root.appendChild(cx.allocator, area.container);
ComponentReturns
Button · Input*Node
ModalModalResult{ overlay, dialog, body, portaled }
TooltipTooltipResult{ wrapper, trigger, content }
SelectSelectMount{ wrapper, trigger, panel, state, is_open }
mountScrollAreaScrollAreaResult{ container, content, state }
mountGridGridResult{ root, state }

Don’t treat config as live props you can write to later. A component reads its config at mount time to build nodes and state, so a signal.get() inside config is read once, as a snapshot. Data that keeps changing flows in through the returned state, a Signal (such as Modal’s .visible(sig)) or the component’s public methods; never keep a pointer to a temporary config across frames. A component’s internal state lives on the child Scope that mount creates and is released when the parent Scope is disposed.

Why overlays must be components

Tooltip, Popover, Menu, Modal, Sheet and the in-app Notifier all register a layer with the OverlayStack. Layers with a barrier (Modal / Sheet) are re-parented to the window-level portal (WindowOverlayPortal under cx.root) so they escape any ancestor’s overflow clipping. Z values come from semantic tiers: overlay 100, dialog 1000, toast 2000 (where the Notifier lives), tooltip 3000, with the devtools overlay fixed at 32000. An overlay opened from inside another always stacks above its host. Absolute positioning and a large z-index on a box give you none of this.

modal.zig
const visible = try scope.createSignal(bool, false);

const modal = try ui.widgets.Modal(.{
    .title = "Delete document?",
    .width = 420,
}).visible(visible).mount(scope, cx);

try modal.body.appendChild(cx.allocator, confirm_content);
// modal.portaled == true: the barrier already lives under the window-level
// portal. Do not append modal.overlay to the current tree.

// Open it from any handler:
visible.set(true);
OVERLAY PORTAL
Where you call mount doesn’t decide where the overlay draws: the barrier goes to the portal, the tier decides z, and Escape walks down from the top of the stack to the first dismissible layer.
  • ✓

    Inside clicks stay inside the dialog / panel and never trigger outside-dismiss; clicking the barrier closes it per close_on_overlay.

  • ✓

    Escape closes only the topmost overlay; nested layers unwind one at a time.

  • ✓

    Focus: an open Modal traps and auto-focuses, then restores focus to where it was on close.

  • ✓

    Scope disposal actively detaches the barrier subtree from the portal, so switching pages never leaves an invisible hit layer behind.

  • ✓

    Exit transitions keep the overlay visible until the animation commits, then suspend it. There is no jump to the end state.

For non-blocking app-wide notifications use ui.widgets.Notifier (it replaces the old ToastManager): Notifier.init(scope, cx, .{}) registers a toast-tier layer and, when the window portal exists, mounts its full-window container there (if portaled is false, append container to your root yourself). Then show(.{ .tone = .success, .title = "Saved" }) returns an id, update transforms a card in place and dismiss retires it. Dismissals, expiry and button actions come back to the app as Listener events; history, do-not-disturb and other notification-center logic belong to the app.

The harness opens a centered dialog and verifies inside clicks don’t dismiss it. Modal
A virtual cursor hovers the trigger; the bubble enters the tooltip tier without touching layout. Tooltip

The showcase is also a regression suite

e2e/storybook.test.ts switches to each story via nav.<key>, collects the subtree’s text and asserts every expected data value is present, then takes a screenshot; interactive components also assert state, geometry or pixels after interacting. The component site’s recordings reuse the same app and the same semantic ids, so the demo and the test never fork into two implementations.

GateWhat it catches
Text / state read-backEmpty panels, callbacks that never write back, wrong filter / page values
Geometry assertionsPadding boxes, portal centering, Sheet edge alignment, drag deltas
Pixel assertionsBlank GPU layers, blend regressions, grayscale emoji, wrong z order
RecordingsTiming of hover, press, cursor, IME, scrolling and enter animations
zenit · Dual-licensedFree for open-source projects under GPL-3.0-only; closed-source or commercial products need a commercial license.Contact the author: zongyi.xzy#gmail.com (replace # with @)zenit 4c50245 2026-10-01