docs/advanced/notifications
Capabilities · Notifier

Notifications

Use the Notifier for in-window notifications that never block the task at hand (success, error, progress, undo and messages). Stacking, hover-to-expand and eight docking positions are handled for you.

9 min read · with real-window recordings

What the Notifier is

ui.widgets.Notifier is a window-level service: initialize it once, then call show from anywhere. Cards live on the toast tier of the window overlay portal (z 2000: above dialogs, below tooltips) and never take part in page layout. Each card owns a fixed node, so removing one never rebuilds the others or replays their entrance. It replaces the earlier ToastManager.

The real Storybook: success, error, warning, progress, action, person, undo, quiet and digest one after another, then a burst of four into a stack, expanded on hover, with the middle card dismissed.

The field-by-field API (Notification, NotifierOptions and every method signature) is on the component site’s Notification page.

Creating a Notifier

Create it once in your root mount function and hand the pointer to whatever needs to notify (for example, app state held by cx.bindState). The Notifier is owned by the Scope you pass and goes away with it.

app.zig
const notifier = try ui.widgets.Notifier.init(scope, cx, .{
    .position = .bottom_center,
    // Keep the stack clear of a 56px toolbar.
    .content_insets = .{ .top = 56 },
    .max_visible = 4,
});
NotifierOptions fieldPurpose
positionOne of eight positions, default .bottom_center; switch at runtime with setPosition
max_visibleHow many cards show when expanded (2–6, default 4)
expand_on_hoverWhen false, hover only pauses timers and does not expand
content_insetsInsets from the window edges (title bar, toolbar, status bar); the 22px docking margin starts here
widthCard width, default 392
stringsUI strings ("now", "Undo", "Reply…", the collapsed badge …) for localization
listenerCallback for user actions and expiry; also settable with setListener

Showing a notification

show takes a ui.widgets.Notification value and returns its id. The Notifier copies every string, so your buffers can be reused right away. Beyond six cards on screen, the oldest leaves with its normal exit animation.

notify.zig
// Success: auto-dismisses after 3.6 s.
_ = try notifier.show(.{
    .tone = .success,
    .title = "Saved to iCloud",
    .body = "3 files synced · 2.1 MB",
});

// Error with two actions: errors stay until the user acts.
const failed = try notifier.show(.{
    .tone = .@"error",
    .title = "Upload failed",
    .body = "network timeout · dist/app.zip",
    .actions = &.{
        .{ .label = "Retry", .primary = true, .tag = "retry" },
        .{ .label = "View log", .tag = "log" },
    },
});

// Undo: a 7 s countdown ring and a default "Undo" button.
_ = try notifier.show(.{
    .kind = .undo,
    .title = "Moved 4 items to the Trash",
    .body = "You can undo this for 7 seconds.",
});

// A person's message with an inline reply box.
_ = try notifier.show(.{
    .tone = .violet,
    .avatar = "L",
    .title = "Lin",
    .body = "I finished the table widths in section three.",
    .reply = true,
});

Kinds, tones and lifetimes

A card’s look comes from two axes: kind (layout) and tone (semantic color). The leading icon is derived from both, or set explicitly with lead / glyph.

kindLayout
.defaultIcon, title and body, with optional buttons, reply box or avatar
.progressSpinner, determinate bar and an n / N count; no life bar
.undoCountdown ring showing seconds left, plus a default "Undo" button
toneUse forDefault lifetime
.successSomething finished3.6 s
.@"error"A failure that needs attentionsticky
.warningWorth noticing, not blocking5.6 s
.infoGeneral information (default)4.2 s
.violetPeople, invitations and other social messages4.2 s; sticky with buttons
.quietLow-key status (a small dot instead of an icon)2.8 s
  • ✓

    Sticky by default: progress cards, cards with a reply box, error tones and violet cards with buttons never auto-dismiss.

  • ✓

    Explicit overrides: sticky = true forces it; duration_ms replaces the default; life_bar toggles the lifetime bar.

  • ✓

    Undo defaults to 7 seconds, with the ring tracking the timer.

In-place updates and progress

update(id, …) rewrites the same card’s kind, tone, title and body and restarts its lifetime: no destroy, no rebuild, no replayed entrance. Drive a progress card with setProgress, which touches only the progress layer. Calling show with an existing id also transforms in place.

upload.zig
const id = try notifier.show(.{
    .kind = .progress,
    .title = "Uploading attachment",
    .body = "assets/hero-shot.png",
    .progress = .{ .value = 0, .label = "Uploading · about 5 s", .count = "0 / 5" },
});

// Each tick: only the progress layer is touched, the card isn't rebuilt.
notifier.setProgress(id, 0.6, "Uploading · about 2 s", "3 / 5");

// Done: the same card turns into a success card in place,
// no new entrance, and it now dismisses itself after 3.4 s.
try notifier.update(id, .{
    .tone = .success,
    .title = "Upload complete",
    .body = "assets/hero-shot.png",
    .duration_ms = 3400,
});

Handling user actions

The Notifier reports through a single ui.widgets.NotificationListener. A NotificationEvent carries the card id, the event kind, and the button tag / index or the reply text.

EventKindWhen
.actionA button was clicked (tell them apart by tag)
.undoUndo was clicked; the ring has already turned into a ✓
.replyThe reply box was submitted (Enter or Send); text holds it
.dismissedThe user closed it (✕ or a fling)
.expiredIt timed out
uploads.zig
const Uploads = struct {
    fn onEvent(ctx: ?*anyopaque, n: *ui.widgets.Notifier, e: ui.widgets.NotificationEvent) void {
        const self: *Uploads = @ptrCast(@alignCast(ctx orelse return));
        switch (e.kind) {
            .action => if (std.mem.eql(u8, e.tag, "retry")) {
                // Turn the sticky error card into a progress card in place.
                n.update(e.id, .{
                    .kind = .progress,
                    .title = "Retrying upload",
                    .progress = .{ .value = 0, .label = "Uploading", .count = "0 / 5" },
                }) catch return;
                self.restart(e.id);
            } else n.dismiss(e.id),
            .reply => self.send(e.text),
            .undo => self.restoreTrash(),
            .dismissed, .expired => {},
        }
    }
    // …restart / send / restoreTrash
};

const uploads = try cx.bindState(Uploads, .{});
notifier.setListener(.{ .context = @ptrCast(uploads), .callback = Uploads.onEvent });

Stacking and hover

Several notifications collapse into a stack: the newest sits closest to the anchor with the highest z; each layer behind peeks out 9px and shrinks 3.8%, up to three layers, with an "N more · hover to expand" badge. When the pointer enters the stack’s bounds (grown by 18px), it expands with 9px gaps and every timer pauses; leaving collapses it and resumes.

STACK
Numbers come from Spec in notification/model.zig: 9px peek, 3.8% scale per layer, 9px expanded gap.
01
Clear all

Next to the badge sits a clear-all button: the first click reveals its label, the second actually clears, and moving away folds it back. In code, call dismissAll().

02
Fling to dismiss

A single card can be flung away, which reports dismissed.

03
Window resize

On window resize the stack follows its anchor rigidly, with no trailing tween.

Positions

setPosition changes only the anchor, growth direction and entrance offset. Cards aren’t rebuilt, and any card that is leaving finishes its exit in the new direction.

POSITIONS
The same four notifications docked at each of the eight positions; whole-window framing, since the left positions hug the window edge.

The notification center is yours

Notification-center features (history, do-not-disturb, a bell entry point) are app logic and live outside zenit: record history where you call show, decide whether to pop a card at all, and learn about dismissals, expiry and button actions through the Listener. The Storybook’s Digest card shows the pattern: after do-not-disturb ends, one quiet card summarizes what arrived.

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