---
title: "Components | zenit Zig UI Docs"
description: "What a zenit component promises, when to drop down a layer, and the three contracts: mounting, overlays and testing."
url: https://ui.surf/zenit/docs/guide/components
language: en
alternate_zh: https://ui.surf/zenit/zh/docs/guide/components.md
alternate_es: https://ui.surf/zenit/es/docs/guide/components.md
alternate_ja: https://ui.surf/zenit/ja/docs/guide/components.md
alternate_ko: https://ui.surf/zenit/ko/docs/guide/components.md
alternate_fr: https://ui.surf/zenit/fr/docs/guide/components.md
alternate_de: https://ui.surf/zenit/de/docs/guide/components.md
project: zenit v0.1.0-alpha (Zig 0.15.2, macOS)
source: https://github.com/version-next/zenit
---

# 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](https://ui.surf/zenit/components).

## 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`.

**51**Stories on the component site

**6**Use-case categories (plus Labs)

**1:1**Story 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

| Need | Layer | You still own |
| --- | --- | --- |
| Standard product controls | `ui.widgets.*` | Business state, copy, callbacks and placement |
| Custom visuals, proven interaction | `ui.hooks` · `ui.interaction` · `ui.select_headless` | Drawing, tokens, a11y labels and composition boundaries |
| A new interaction pattern | `ui.Node` + `ui.events` | Hit-testing, keyboard, focus, IME, a11y and the test contract |

> TIP
> 
> **Start with widgets.** Only drop a layer when an existing component’s behavior model doesn’t fit. A different look is rarely worth re-implementing focus, IME, overlay dismissal and accessibility.

## Catalog

Every component has its own page on the [component site](https://ui.surf/zenit/components): 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.

| Category | Count | Examples |
| --- | --- | --- |
| Actions | 7 | [Button](https://ui.surf/zenit/components/button) · [Checkbox](https://ui.surf/zenit/components/checkbox) · [Switch](https://ui.surf/zenit/components/switch) · [RadioGroup](https://ui.surf/zenit/components/radio) · [Slider](https://ui.surf/zenit/components/slider) … |
| Inputs | 10 | [Input](https://ui.surf/zenit/components/input) · [Textarea](https://ui.surf/zenit/components/textarea) · [Select](https://ui.surf/zenit/components/select) · [Input · Select · Button](https://ui.surf/zenit/components/formcompose) · [ComboBox](https://ui.surf/zenit/components/combobox) … |
| Display | 17 | [Badge](https://ui.surf/zenit/components/badge) · [Tag](https://ui.surf/zenit/components/tag) · [Chip](https://ui.surf/zenit/components/chip) · [Card](https://ui.surf/zenit/components/card) · [Alert](https://ui.surf/zenit/components/alert) … |
| Navigation | 4 | [Tabs](https://ui.surf/zenit/components/tabs) · [Accordion](https://ui.surf/zenit/components/accordion) · [Menu](https://ui.surf/zenit/components/menu) · [DropdownMenu](https://ui.surf/zenit/components/dropdown) |
| Overlays | 5 | [Notifier](https://ui.surf/zenit/components/notification) · [Tooltip](https://ui.surf/zenit/components/tooltip) · [Popover](https://ui.surf/zenit/components/popover) · [Modal](https://ui.surf/zenit/components/modal) · [Sheet](https://ui.surf/zenit/components/sheet) |
| Layout | 8 | [GlassBox](https://ui.surf/zenit/components/glassbox) · [Divider](https://ui.surf/zenit/components/divider) · [HStack / VStack](https://ui.surf/zenit/components/stack) · [ui.box](https://ui.surf/zenit/components/layoutbox) · [VirtualList](https://ui.surf/zenit/components/virtuallist) … |
| Labs | 13 | [glasslab](https://ui.surf/zenit/components/glasslab) · [glassislands](https://ui.surf/zenit/components/glassislands) · [glassmotion](https://ui.surf/zenit/components/glassmotion) · [glasschrome](https://ui.surf/zenit/components/glasschrome) · [canvasevents](https://ui.surf/zenit/components/canvasevents) … |

[Video](https://ui.surf/zenit/media/stories/button.mp4?v=6203a01415)

The Button story sweeps the variant × size matrix, then hovers and presses Primary. Details at [/components/button](https://ui.surf/zenit/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`

```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`

```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);
```

| Component | Returns |
| --- | --- |
| `Button` · `Input` | `*Node` |
| `Modal` | `ModalResult{ overlay, dialog, body, portaled }` |
| `Tooltip` | `TooltipResult{ wrapper, trigger, content }` |
| `Select` | `SelectMount{ wrapper, trigger, panel, state, is_open }` |
| `mountScrollArea` | `ScrollAreaResult{ container, content, state }` |
| `mountGrid` | `GridResult{ 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](https://ui.surf/zenit/components/notification) 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`

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

[Video](https://ui.surf/zenit/media/stories/modal.mp4?v=f1b080996c)

The harness opens a centered dialog and verifies inside clicks don’t dismiss it. [Modal](https://ui.surf/zenit/components/modal)

[Video](https://ui.surf/zenit/media/stories/tooltip.mp4?v=64c28daff2)

A virtual cursor hovers the trigger; the bubble enters the tooltip tier without touching layout. [Tooltip](https://ui.surf/zenit/components/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.

| Gate | What it catches |
| --- | --- |
| Text / state read-back | Empty panels, callbacks that never write back, wrong filter / page values |
| Geometry assertions | Padding boxes, portal centering, Sheet edge alignment, drag deltas |
| Pixel assertions | Blank GPU layers, blend regressions, grayscale emoji, wrong z order |
| Recordings | Timing of hover, press, cursor, IME, scrolling and enter animations |

> WARNING
> 
> **Storybook isn’t your business components.** Editors, file browsers, command palettes and domain models belong to the app. The public library only promises reusable behavior and visual primitives.
