DEV Community

Cover image for Building a Mini Dashboard Widget in Tauri — The Menubar Mini-Dash Pattern
hiyoyo
hiyoyo

Posted on

Building a Mini Dashboard Widget in Tauri — The Menubar Mini-Dash Pattern

All tests run on an 8-year-old MacBook Air. All results from shipping 7 Mac apps as a solo developer. No sponsored opinion.

HiyokoAutoSync has a Mini-Dash — a compact menubar widget showing sync status, device connection, and quick actions. It lives separately from the main window and is always one click away. Here's the pattern.


Why a Mini-Dash

The main app window has full controls. The mini-dash has just the essentials:

  • Is the device connected?
  • Is sync running?
  • Last sync time
  • One-click sync trigger

Users who set up sync and mostly let it run don't want to open the full app every time. The mini-dash is for them. It's the difference between a tool that stays out of your way and one that doesn't.


Two-Window Tauri Setup

tauri::Builder::default()
    .setup(|app| {
        // Main window
        let _main = WebviewWindowBuilder::new(
            app,
            "main",
            WebviewUrl::App("index.html".into())
        )
        .title("HiyokoAutoSync")
        .inner_size(800.0, 600.0)
        .visible(false)
        .build()?;

        // Mini-dash window
        let _mini = WebviewWindowBuilder::new(
            app,
            "mini",
            WebviewUrl::App("mini.html".into())
        )
        .title("HiyokoAutoSync Mini")
        .inner_size(320.0, 200.0)
        .always_on_top(true)
        .decorations(false)
        .resizable(false)
        .visible(false)
        .build()?;

        Ok(())
    })
Enter fullscreen mode Exit fullscreen mode

Two separate windows, separate HTML entry points, same Rust backend. The key settings for the mini-dash:

Setting Value Reason
always_on_top true stays visible over other windows
decorations false no titlebar — pure content
resizable false fixed compact size
visible false hidden until tray click

Positioning the Mini-Dash

A mini-dash that appears at a random position feels broken. Use tauri-plugin-positioner to snap it below the tray icon:

# Cargo.toml
tauri-plugin-positioner = { version = "2", features = ["tray-icon"] }
Enter fullscreen mode Exit fullscreen mode
fn toggle_mini_dash(app: &tauri::AppHandle) {
    if let Some(window) = app.get_webview_window("mini") {
        if window.is_visible().unwrap_or(false) {
            let _ = window.hide();
        } else {
            let _ = window.move_window(Position::TrayCenter);
            let _ = window.show();
            let _ = window.set_focus();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Without this, always_on_top windows tend to appear wherever they were last positioned — which is rarely where the user expects.


Shared State Between Windows

Both windows talk to the same Rust backend. Broadcast events to all windows at once:

// Broadcast to all windows
app_handle.emit("sync-status-changed", &status).ok();

// Or target a specific window
app_handle
    .get_webview_window("mini")
    .unwrap()
    .emit("sync-status-changed", &status)
    .ok();
Enter fullscreen mode Exit fullscreen mode

The mini-dash listens for status events and updates its compact UI. The main window does the same with its full UI. One event, two listeners — no duplication.


Compact UI Design

The mini-dash is 320x200px. Every element earns its space:

export function MiniDash() {
    const [status, setStatus] = useState<SyncStatus>()

    useEffect(() => {
        listen<SyncStatus>('sync-status-changed', (e) => {
            setStatus(e.payload)
        })
    }, [])

    return (
        <div className="p-3 space-y-2">
            <DeviceIndicator connected={status?.deviceConnected} />
            <SyncStatusLine status={status?.syncState} />
            <LastSyncTime time={status?.lastSync} />
            <button onClick={() => invoke('trigger_sync')}>
                Sync Now
            </button>
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

Four elements. That's the whole UI. Resist the urge to add more — the value of a mini-dash is what it leaves out.


Tray Icon Integration

Left click → mini-dash. Right click → context menu with full options.

TrayIconBuilder::new()
    .on_tray_icon_event(|tray, event| {
        // Pass event to positioner first
        tauri_plugin_positioner::on_tray_event(tray.app_handle(), &event);

        match event {
            TrayIconEvent::Click {
                button: MouseButton::Left,
                button_state: MouseButtonState::Up,
                ..
            } => {
                toggle_mini_dash(tray.app_handle());
            }
            _ => {}
        }
    })
    .menu(&menu) // Right-click: "Open Main Window", "Quit"
    .show_menu_on_left_click(false)
    .build(app)?;
Enter fullscreen mode Exit fullscreen mode

NOTE: tauri_plugin_positioner::on_tray_event() を呼ぶのを忘れると、Position::TrayCenter が正しく動きません。


If this was useful, a ❤️ helps more than you'd think!
HiyokoAutoSync → https://hiyokomtp.lemonsqueezy.com/checkout/buy/20c922f1-ca45-4f77-aeb2-04e34aad2fb4

X → @hiyoyok

Top comments (1)

Collapse
 
hiyoyok profile image
hiyoyo

TL;DR:

  • Two separate Tauri windows (main + mini) with the same Rust backend
  • Use tauri-plugin-positioner with Position::TrayCenter to snap the mini-dash below the tray icon
  • Broadcast state with app_handle.emit() — one event, both windows update
  • Left click → mini-dash, right click → context menu
  • Keep the UI to 4 elements max — the value is what you leave out