Part of Tauri Patterns for Production

Tauri Patterns for Production: Build a Menu Bar Timer in Tauri 2

Celest KimCelest Kim

Video: Build a Menu Bar Timer in Tauri 2 | System Tray Plugin Capstone by CelesteAI

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

A menu-bar app is a different animal from a regular desktop window. There is no dock icon and no big main window — just a small icon in the system tray that, when clicked, pops up a little panel. Hit the same icon again and the panel hides. Quit from a tray menu, not from a window close button. The whole thing has a different relationship to the operating system, and Tauri 2 has direct support for it via tauri::tray.

This is the capstone episode of the Tauri Patterns for Production series. We’re building Tomato, a Pomodoro timer that lives in the menu bar. Click the tray icon, a 320×360 panel slides in with a 25:00 countdown and Start / Pause / Reset buttons. Hit Start; the clock ticks down. When the session ends, an OS-native notification fires and a daily-completions counter persists to disk. Click the tray again and the panel disappears.

The whole app is about 120 lines of Rust and 60 lines of TSX. Every prior episode in this series shows up somewhere:

  • Ep 1 (Capabilities + bundle config) — the new permissions we grant for tray, notification, store.
  • Ep 3 (Plugins: fs, dialog, notification)tauri-plugin-notification fires the “session done” toast.
  • Ep 4 (Multi-window)WebviewWindow.show() / .hide() from the tray click toggles the popup.
  • Ep 5 (Persist state to disk)tauri-plugin-store saves the completed-today counter.
  • Ep 7 (Mutex) — the timer state (running, started_at, remaining_secs) lives in Mutex<TimerState>.
  • Ep 9 (CI matrix) — the workflow we built in Ep 9 still ships this app to Mac, Windows, and Linux without changes.

Synthesis matters because it shows the patterns aren’t islands. A real app weaves them together, and the boundaries between them are how clean code stays clean.


What a menu-bar app actually is

The visible difference vs a standard window app is mostly window config plus one new builder. The headline changes to tauri.conf.json:

"app": {
  "windows": [
    {
      "label": "main",
      "title": "Tomato",
      "width": 320,
      "height": 360,
      "resizable": false,
      "visible": false,
      "decorations": false,
      "transparent": true,
      "alwaysOnTop": true,
      "skipTaskbar": true
    }
  ]
}

Five flags do the work:

  • visible: false — the window doesn’t appear on launch. The user sees nothing in their dock or task list until they click the tray icon.
  • decorations: false — no title bar, no traffic-light buttons, no chrome. The popup is just our content.
  • transparent: true — the window background is transparent at the OS level, so we can have rounded corners and a custom shape rendered in CSS.
  • alwaysOnTop: true — the panel stays above other windows. Standard menu-bar app behavior; the panel doesn’t get hidden behind your browser when you context-switch.
  • skipTaskbar: true — on Windows, the app doesn’t show up in the taskbar at all. On macOS, you’d combine this with LSUIElement: true in the Info.plist to hide the dock icon — outside the scope of this tutorial but a one-line bundle config away.

The tray icon and its menu

tauri::tray is the Rust API. The shape is TrayIconBuilder + a menu + handlers:

.setup(|app| {
  let show = MenuItemBuilder::with_id("show", "Show Tomato").build(app)?;
  let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
  let menu = MenuBuilder::new(app).items(&[&show, &quit]).build()?;

  TrayIconBuilder::with_id("main")
    .menu(&menu)
    .show_menu_on_left_click(false)
    .icon(app.default_window_icon().unwrap().clone())
    .on_menu_event(|app, event| match event.id().as_ref() {
      "show" => toggle_window(app),
      "quit" => app.exit(0),
      _ => {}
    })
    .on_tray_icon_event(|tray, event| {
      if let TrayIconEvent::Click {
        button: MouseButton::Left,
        button_state: MouseButtonState::Up,
        ..
      } = event
      {
        toggle_window(tray.app_handle());
      }
    })
    .build(app)?;
  Ok(())
})

This is all inside the .setup(|app| { ... }) closure on the Builder. Three pieces:

  1. The menu is the right-click context menu — two items, with stable string IDs (show, quit). IDs are how you tell which item was clicked in the handler.
  2. show_menu_on_left_click(false) — left click does NOT pop the menu. Instead it fires the tray-icon event below, which we use to toggle the panel. The right-click still pops the menu.
  3. on_tray_icon_event matches a Click { button: Left, button_state: Up } pattern and calls toggle_window. The Up state matters — Down fires on press, Up on release. Using Up gives the usual click feel.

toggle_window is the simple show/hide pair:

fn toggle_window(app: &AppHandle) {
  if let Some(win) = app.get_webview_window("main") {
    if win.is_visible().unwrap_or(false) {
      let _ = win.hide();
    } else {
      let _ = win.show();
      let _ = win.set_focus();
    }
  }
}

get_webview_window("main") looks up our window by label. If it’s visible, hide it. If hidden, show it and give it focus. The _ = ... discards the Result — we don’t care if focus fails for some reason; we’re not going to retry.

For a polished menu-bar app you’d also position the popup directly under the tray icon. The tray click event includes a position field with the icon’s screen coordinates; you’d call win.set_position() before show(). We skip that here for brevity — the OS-default window position works fine for a tutorial.


Timer state with Mutex

The timer needs three coupled fields:

#[derive(Default)]
struct TimerState {
  running: bool,
  started_at: Option<Instant>,
  remaining_secs: u64,
}

running is the on/off flag. started_at is when the current run began. remaining_secs is how much was left at the previous pause (or 25×60 if fresh). Same shape as the stopwatch from Ep 7 — running + started_at + accumulator — adapted from counting up to counting down.

The state is wrapped in a Mutex and registered on the Builder:

.manage(Mutex::new(TimerState {
  remaining_secs: SESSION_SECS,
  ..Default::default()
}))

Four commands manipulate it:

  • start — if not running, set running=true and stamp started_at. If remaining_secs is 0 (timer just finished), reset to a fresh 25 minutes.
  • pause — compute current remaining (using current_remaining()), store it, clear started_at, set running=false. Now we know exactly how much was left when paused; Start resumes from that.
  • reset — set everything back to a fresh 25-minute session.
  • tick — called by the frontend every 500ms. Returns (remaining_secs, is_running). If the timer hit zero this tick AND was previously running, also fire a desktop notification.

The tick command is where notifications integrate:

let _ = app
  .notification()
  .builder()
  .title("Pomodoro complete")
  .body("Time for a break.")
  .show();

tauri-plugin-notification exposes .notification() on AppHandle via the NotificationExt trait. The builder pattern lets you chain title + body + (optionally) icon + sound. .show() fires the OS notification and returns immediately — fire-and-forget. The notification appears in macOS Notification Center, Windows Action Center, or the relevant Linux equivalent.


Persisting completed-today with plugin-store

The “completed today” counter survives quits and relaunches. tauri-plugin-store is the right tool — small JSON file on disk, key/value API, autosave on every mutation.

On the frontend:

const store = await load("tomato.json", { autoSave: true });
const val = (await store.get<number>("completed_today")) ?? 0;
setCompleted(val);

load opens (or creates) a store backed by tomato.json in the app’s data directory. With autoSave: true, every set/delete persists to disk immediately. The ?? 0 gives a default when the key doesn’t exist yet (first launch).

When the timer hits zero, the tick callback in useEffect increments the counter:

if (wasRunning && !isRunning && r === 0) {
  const store = await load("tomato.json", { autoSave: true });
  const next = ((await store.get<number>("completed_today")) ?? 0) + 1;
  await store.set("completed_today", next);
  setCompleted(next);
}

wasRunning && !isRunning && r === 0 is the “just transitioned from running to done” edge condition — only fires once per completed session, not every tick after.

For a more sophisticated app you’d also reset the counter at midnight (key by date: completed_2026-05-17). One extra line; not strictly needed for the demo.


The frontend in 50 lines

import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { load } from "@tauri-apps/plugin-store";

function format(secs: number) {
  const mm = Math.floor(secs / 60);
  const ss = secs % 60;
  return `${String(mm).padStart(2, "0")}:${String(ss).padStart(2, "0")}`;
}

export default function App() {
  const [remaining, setRemaining] = useState(25 * 60);
  const [running, setRunning] = useState(false);
  const [completed, setCompleted] = useState(0);

  useEffect(() => {
    // Load completed counter on mount.
    (async () => {
      const store = await load("tomato.json", { autoSave: true });
      setCompleted((await store.get<number>("completed_today")) ?? 0);
    })();

    // Poll every 500ms.
    const id = setInterval(async () => {
      const [r, isRunning] = await invoke<[number, boolean]>("tick");
      const wasRunning = running;
      setRemaining(r);
      setRunning(isRunning);
      if (wasRunning && !isRunning && r === 0) {
        const store = await load("tomato.json", { autoSave: true });
        const next = ((await store.get<number>("completed_today")) ?? 0) + 1;
        await store.set("completed_today", next);
        setCompleted(next);
      }
    }, 500);
    return () => clearInterval(id);
  }, [running]);

  return (
    <main className="container">
      <div className="clock">{format(remaining)}</div>
      <div className="row">
        {!running ? (
          <button onClick={() => invoke("start")}>Start</button>
        ) : (
          <button onClick={() => invoke("pause")}>Pause</button>
        )}
        <button onClick={() => invoke("reset")}>Reset</button>
      </div>
      <div className="counter">Completed today: {completed}</div>
    </main>
  );
}

setInterval polling at 500ms is fine for a clock that updates per second. Lower the interval if you want smoother countdowns. Tauri events would also work for “session done” — emit from Rust, listen on the frontend — but for a simple state machine with one-shot transitions, polling is enough.


Capability permissions

The capability gains five new permissions over a normal window app:

"permissions": [
  "core:default",
  "core:window:allow-show",
  "core:window:allow-hide",
  "core:window:allow-set-focus",
  "notification:default",
  "store:default"
]
  • core:window:allow-show / -hide / -set-focus — these are the granular window-manipulation permissions. By default windows can’t show/hide themselves from JS; you grant the specific permissions the toggle pattern needs.
  • notification:default — let the frontend (and Rust commands invoked from it) fire notifications.
  • store:default — let the frontend load/get/set on plugin-store.

Each plugin you add typically adds one default permission entry. Tauri’s permission model is opt-in: nothing the frontend can do unless an explicit capability grants it. If you forget one of these, the call fails with a permission error in the dev console.


Gotchas worth knowing

A few things bite the first time you build a tray app:

The window position is not under the tray icon by default. Tauri shows the window at the OS-default position, which is usually the top-left of the primary monitor. For a real menu-bar app, you’d compute position from the tray-icon click event and call win.set_position() before show(). Two extra lines, polish-only.

skipTaskbar doesn’t hide the dock icon on macOS. On Mac, the dock icon is controlled by LSUIElement in the bundle’s Info.plist. Set it via Tauri’s bundle config: "macOS": { "LSUIElement": true }. After that the app runs without a dock icon at all — true menu-bar app feel.

The tray icon needs to be visible to the OS at the right size. macOS expects template icons (single color with transparency); Windows wants ICO with multiple resolutions; Linux varies. default_window_icon() works for the demo but for production you’d ship a dedicated tray icon (templating on macOS, dedicated ICO on Windows). Tauri has separate icon paths for this.

Notifications need OS permission on macOS. The first call prompts the user; subsequent calls fire silently if granted. If denied, calls return Err but don’t crash. You’d typically check permission status on first launch with app.notification().permission_state() and prompt explicitly via request_permission() if needed.

Polling vs event-driven for the timer. We poll every 500ms because the state lives in Rust and we only update once per second. For a higher-frequency UI (like a millisecond stopwatch), you’d push events from Rust via app.emit("tick", ...) and listen in React with listen("tick", handler). Push is more efficient when updates are frequent; polling is simpler when updates are slow.


What this app DOESN’T do (production checklist)

The list of “things still needed before this is a real product” is the actual ending of the series. In rough priority:

  • App icon variants. Real tray icons (template-mode SVG/PNG for Mac, multi-res ICO for Windows). The default icon works in dev but is the wrong shape for a menu bar.
  • Position the popup under the tray icon. Use the tray-event’s position field plus your monitor’s size to compute the right top-left for the window. ~10 lines.
  • Hide the dock icon on macOS. LSUIElement: true in bundle config. One-line change, dramatic effect.
  • Auto-reset the counter at midnight. Key the completed-today count by date (e.g., completed_2026-05-17) so it rolls over automatically.
  • Custom session length. Right now 25 minutes is hardcoded. A settings window (Ep 4 callback) or a tray submenu (25/15/5) makes it configurable.
  • Sound effects. Notification sound on session end. Adds 5 lines via notification().sound(...).
  • Code signing + notarization. Required for users to install without “damaged” warnings on macOS, and for SmartScreen to trust the app on Windows.
  • Telemetry / crash reporting. Sentry or similar. Lets you know when users hit panics in the wild.

Each one is an episode in its own right. You could do another whole series just on “the production polish” of a Tauri app — but most of those concerns apply equally to any desktop framework, and Tauri’s docs cover them well once you have the basics.


What we covered across the series

Putting all ten in one list for the wrap:

  1. Project setup — the file structure, capabilities, plugins, bundle.
  2. IPC — commands, events, state.
  3. Plugins — fs, dialog, notification.
  4. Multi-window — WebviewWindow API, label-based lookup.
  5. Persist state — plugin-store, autosave.
  6. SQLite — plugin-sql, migrations.
  7. Global state — Mutex, the State extractor.
  8. Self-update — signing keys, manifest, the updater plugin.
  9. CI matrix — GitHub Actions for three platforms.
  10. Capstone — system tray, menu, popup window, everything together.

Ten episodes is enough to ship a real Tauri app. The patterns compose. Most non-trivial Tauri apps will use 5-7 of these directly, and the remaining ones are decisions you’d make once and codify in your project template.

That’s the series.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.