---
title: "Notifications | zenit Zig UI Docs"
description: "Use the Notifier for in-window notifications that never block the task at hand (success, error, progress, undo and messages)."
url: https://ui.surf/zenit/docs/advanced/notifications
language: en
alternate_zh: https://ui.surf/zenit/zh/docs/advanced/notifications.md
alternate_es: https://ui.surf/zenit/es/docs/advanced/notifications.md
alternate_ja: https://ui.surf/zenit/ja/docs/advanced/notifications.md
alternate_ko: https://ui.surf/zenit/ko/docs/advanced/notifications.md
alternate_fr: https://ui.surf/zenit/fr/docs/advanced/notifications.md
alternate_de: https://ui.surf/zenit/de/docs/advanced/notifications.md
project: zenit v0.1.0-alpha (Zig 0.15.2, macOS)
source: https://github.com/version-next/zenit
---

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

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

[Video](https://ui.surf/zenit/media/stories/notification.mp4?v=5f8cadb85b)

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

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

```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 field | Purpose |
| --- | --- |
| `position` | One of eight positions, default .bottom\_center; switch at runtime with setPosition |
| `max_visible` | How many cards show when expanded (2–6, default 4) |
| `expand_on_hover` | When false, hover only pauses timers and does not expand |
| `content_insets` | Insets from the window edges (title bar, toolbar, status bar); the 22px docking margin starts here |
| `width` | Card width, default 392 |
| `strings` | UI strings ("now", "Undo", "Reply…", the collapsed badge …) for localization |
| `listener` | Callback 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`

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

> NOTE
> 
> **At most two buttons.** Buttons sit under a divider at equal widths on a neutral fill; primary only means a heavier fill and weight. It never takes the tone color.

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

| kind | Layout |
| --- | --- |
| `.default` | Icon, title and body, with optional buttons, reply box or avatar |
| `.progress` | Spinner, determinate bar and an n / N count; no life bar |
| `.undo` | Countdown ring showing seconds left, plus a default "Undo" button |

| tone | Use for | Default lifetime |
| --- | --- | --- |
| `.success` | Something finished | 3.6 s |
| `.@"error"` | A failure that needs attention | sticky |
| `.warning` | Worth noticing, not blocking | 5.6 s |
| `.info` | General information (default) | 4.2 s |
| `.violet` | People, invitations and other social messages | 4.2 s; sticky with buttons |
| `.quiet` | Low-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`

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

| EventKind | When |
| --- | --- |
| `.action` | A button was clicked (tell them apart by tag) |
| `.undo` | Undo was clicked; the ring has already turned into a ✓ |
| `.reply` | The reply box was submitted (Enter or Send); text holds it |
| `.dismissed` | The user closed it (✕ or a fling) |
| `.expired` | It timed out |

`uploads.zig`

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

> TIP
> 
> **Reply failed to send?** Call `notifier.replyFailed(id, title, body)` to turn the same card into a failure state in place without losing what the user typed.

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

**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().

**Fling to dismiss**

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

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

[Video](https://ui.surf/zenit/media/stories/notification-positions.mp4?v=2df7ff87b6)

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.

> WARNING
> 
> **Accessibility is not signed off.** As with the rest of zenit, VoiceOver acceptance for notification cards isn’t done yet; don’t make them the only channel for critical information.
