Tray Icon
A cross-platform system tray / status-bar / menu-bar icon for .NET MAUI desktop apps. Set an icon, attach a context menu, listen to clicks. The same MAUI code lights up the right native API on each platform:
- Windows —
Shell_NotifyIcon(Win32) with a hidden message-only window for click routing - macOS (AppKit) —
NSStatusBar.SystemStatusBarvia the nativenet10.0-macosbindings - MacCatalyst — bridges to AppKit at runtime through the Objective-C runtime
- Linux —
libayatana-appindicator3+ GTK 3 (requires the system package to be installed)
This ships as a separate package — Shiny.Maui.Controls.TrayIcon — because the tray-icon TFM matrix is desktop-only and pulls in platform targets the main controls package does not.
There is no Blazor equivalent: a tray icon is a desktop OS concept.
Features
Section titled “Features”- One API across Windows, macOS AppKit, MacCatalyst, and Linux
- Fluent menu builder with items, check items, separators, and submenus
- Per-menu-item icons (
TrayMenuItem.Icon) — aFunc<Stream>per item, native rendering on all four platforms - Keyboard accelerator dispatch —
TrayMenuItem.Accelerator(e.g."Ctrl+S") is registered with the OS so the shortcut actually fires - Badge / overlay numbers (
Badgeproperty) — composited onto the icon on Windows, displayed beside the icon on macOS / Linux - Balloon / toast notifications (
ShowNotification(title, message)) — WindowsNIF_INFO, macOS / CatalystNSUserNotificationCenter, Linuxlibnotify - Animated icons (
StartAnimation/StopAnimation) — cycle a list of frame stream factories on a built-in timer - Mutate any menu item property (
Label,IsEnabled,IsVisible, check state) and the native menu rebuilds automatically - Primary / secondary / double-click events with screen coordinates
- Tooltip, optional title/label (macOS/Linux), runtime show/hide
- macOS template-image flag for automatic light/dark menu bar tinting
- PNG bytes work on every platform — Windows transparently wraps them in an ICO container
- AOT-compatible: P/Invoke +
[UnmanagedCallersOnly]trampolines, no reflection
dotnet add package Shiny.Maui.Controls.TrayIconIn MauiProgram.cs:
using Shiny;
var builder = MauiApp.CreateBuilder();builder .UseMauiApp<App>() .UseTrayIcon();UseTrayIcon() registers ITrayIconFactory as a singleton and picks the correct per-platform implementation automatically. On platforms with no tray concept (Android, iOS), factory.Create() throws PlatformNotSupportedException, so guard with OperatingSystem.IsWindows() || OperatingSystem.IsMacCatalyst() || OperatingSystem.IsMacOS() || OperatingSystem.IsLinux() if the same MAUI app runs on mobile.
Quick Start
Section titled “Quick Start”Resolve ITrayIconFactory from DI, then create as many icons as you need. Always Dispose() when you’re done.
public class MyTrayHost(ITrayIconFactory factory){ ITrayIcon? icon;
public void Start() { this.icon = factory.Create(); this.icon.Tooltip = "My App"; this.icon.IsTemplateImage = true; // macOS auto-tint this.icon.SetIcon(() => FileSystem.OpenAppPackageFileAsync("trayicon.png").Result);
this.icon.SetMenu(TrayMenu.Build(b => b .Item(new TrayMenuItem("Show window", ShowMainWindow) { Accelerator = "Ctrl+Shift+W", Icon = () => FileSystem.OpenAppPackageFileAsync("show.png").Result }) .Check("Notifications", true, on => SetNotifications(on)) .Separator() .Submenu("Status", s => s .Item("Available", () => SetStatus(Status.Available)) .Item("Busy", () => SetStatus(Status.Busy)) .Item("Away", () => SetStatus(Status.Away))) .Separator() .Item(new TrayMenuItem("Quit", () => Application.Current!.Quit()) { Accelerator = "Ctrl+Q" })));
this.icon.PrimaryClick += (_, _) => ShowMainWindow(); this.icon.DoubleClick += (_, _) => OpenSettings();
this.icon.Badge = "3"; // overlay on Windows, beside icon on macOS/Linux this.icon.ShowNotification("Online", "Sync resumed."); // OS-level balloon / toast }
public void Stop() => this.icon?.Dispose();}Pass a Func<Stream> (not a Stream directly) so the handler can re-read the icon for DPI or theme changes.
Building Menus
Section titled “Building Menus”TrayMenu.Build(b => …) is the recommended way to construct menus. The underlying TrayMenu is an ObservableCollection, so you can also build it imperatively and mutate it at any time — the platform handler subscribes to changes and rebuilds the native menu automatically.
var pauseSync = new TrayMenuItem("Pause sync", () => Pause());
var menu = new TrayMenu();menu.Items.Add(pauseSync);menu.Items.Add(new TraySeparator());menu.Items.Add(new TrayMenuItem("Quit", () => Application.Current!.Quit()));
icon.SetMenu(menu);
// Later — these mutations rebuild the menu automatically:pauseSync.Label = "Resume sync";pauseSync.IsEnabled = !syncing;Menu item types
Section titled “Menu item types”| Type | Builder method | Notes |
|---|---|---|
TrayMenuItem | .Item(label, action) or .Item(TrayMenuItem) | Standard clickable item. Optional Icon (Func<Stream>) and Accelerator |
TrayCheckMenuItem | .Check(label, isChecked, toggled) | Renders a check state. Toggled callback receives the new value |
TraySeparator | .Separator() | Visual separator |
TraySubmenu | .Submenu(label, builder) | Nested menu — same fluent API |
All items expose IsEnabled, IsVisible, and Label. TrayMenuItem additionally exposes Icon and Accelerator — see the sections below for native behaviour.
Per-menu-item icons
Section titled “Per-menu-item icons”Set TrayMenuItem.Icon to a stream factory returning a PNG. The factory is invoked each time the menu rebuilds, so it must produce a fresh stream on every call.
new TrayMenuItem("Refresh", Refresh){ Icon = () => FileSystem.OpenAppPackageFileAsync("refresh.png").Result}| Platform | Mechanism |
|---|---|
| Windows | SetMenuItemInfoW + 32bpp pre-multiplied alpha HBITMAP (auto-sized to SM_CXMENUCHECK) |
| macOS | NSMenuItem.Image (16×16, respects template-image semantics) |
| MacCatalyst | Same as macOS via objc_msgSend setImage: + setSize: |
| Linux | gtk_image_menu_item_* — deprecated in GTK 3.10 but still functional. Some GNOME hosts hide menu-item icons by policy |
Hardware keyboard accelerator dispatch
Section titled “Hardware keyboard accelerator dispatch”Accelerator is parsed by the shared TrayAccelerator record and used both as the visible hint and the dispatch wiring. Modifier tokens (case-insensitive, + separated): Ctrl/Control, Alt/Option/Opt, Shift, Cmd/Command/Meta/Win/Super. The key is a single letter/digit, F1..F24, or one of: Esc, Enter/Return, Tab, Space, Backspace, Delete, Insert, Home, End, PageUp, PageDown, Left, Up, Right, Down.
var parsed = TrayAccelerator.Parse("Ctrl+Shift+P");// parsed.Modifiers => Control | Shift, parsed.Key => "P"| Platform | Mechanism | Scope |
|---|---|---|
| Windows | RegisterHotKey on the tray’s hidden message-only window, dispatched via WM_HOTKEY | Global system hotkey while the process is running |
| macOS (AppKit) | NSMenuItem.KeyEquivalent + KeyEquivalentModifierMask | App-wide while foreground |
| MacCatalyst | Same as AppKit via objc_msgSend | App-wide while foreground |
| Linux | gtk_widget_add_accelerator on a GtkAccelGroup attached to the menu | Best-effort — fires while the indicator menu is open or focused |
Unparseable or unregisterable accelerators (unknown key name, modifier-only string, OS-level collision) silently fall back to display-hint-only behaviour.
ITrayIcon API
Section titled “ITrayIcon API”| Member | Description |
|---|---|
SetIcon(Func<Stream>) | Set the icon from a stream factory. PNG or ICO bytes both work — Windows auto-wraps PNG as ICO |
Tooltip | Hover tooltip (Windows / macOS) or accessible description (Linux) |
Title | Optional text label shown beside or instead of the icon on macOS and Linux. Ignored on Windows |
Badge | Optional string composited onto the icon (Windows) or shown beside it (macOS / Linux). Set to null to clear |
IsVisible | Show or hide without disposing |
IsTemplateImage | macOS only — when true, the icon is a template image and auto-tints for the light/dark menu bar. Supply a flat black-on-transparent PNG |
SetMenu(TrayMenu) | Assign or replace the context menu |
ShowMenu() | Programmatically open the menu — useful from a PrimaryClick handler on Windows |
ShowNotification(title, message) | Best-effort OS-level balloon / toast (Windows NIF_INFO, macOS / Catalyst NSUserNotificationCenter, Linux libnotify). For in-app toasts inside your MAUI UI use Shiny.Maui.Controls.Toast instead |
StartAnimation(IReadOnlyList<Func<Stream>>, TimeSpan) | Cycle the supplied frames on a shared System.Threading.Timer. Calling again replaces the running animation |
StopAnimation() | Stop the active animation and restore the last static icon |
IsAnimating | true while an animation is running |
PrimaryClick | Left-click. On macOS, primary-click already opens the menu when one is assigned |
SecondaryClick | Right-click / control-click |
DoubleClick | Windows + macOS only — Linux has no double-click signal |
Dispose() | Removes the tray icon and frees native resources |
TrayClickEventArgs carries X / Y screen coordinates (best-effort across platforms).
Badge / overlay numbers
Section titled “Badge / overlay numbers”icon.Badge = unread.ToString(); // "3" overlay on Windows; "3" beside icon on macOS/Linuxicon.Badge = null; // clear- Windows: the current icon is re-rendered with a rounded red pill containing the badge text in the bottom-right corner. Long strings (more than 3 chars) are truncated to
"xx+". Compositing usesSystem.Drawing.Common, pulled in only for the Windows TFM. - macOS / MacCatalyst: the badge string is appended to the status button title (combined with
Titleif both are set). - Linux: the badge is set on
app_indicator_set_labelalongsideTitle.
Balloon / toast notifications
Section titled “Balloon / toast notifications”icon.ShowNotification("Sync complete", "Uploaded 12 files.");This is a system-level notification — Action Center on Windows, Notification Center on macOS, the desktop notifier daemon on Linux. For richer in-app toasts living inside your MAUI window, use Shiny.Maui.Controls.Toast instead.
On Linux, ShowNotification lazily initializes libnotify on first call. If the library is missing, the call silently no-ops.
Animated icon ticking
Section titled “Animated icon ticking”var frames = new Func<Stream>[]{ () => FileSystem.OpenAppPackageFileAsync("spin-0.png").Result, () => FileSystem.OpenAppPackageFileAsync("spin-1.png").Result, () => FileSystem.OpenAppPackageFileAsync("spin-2.png").Result, () => FileSystem.OpenAppPackageFileAsync("spin-3.png").Result};
icon.StartAnimation(frames, TimeSpan.FromMilliseconds(150));// latericon.StopAnimation(); // restores the last static icon set via SetIconThe timer is owned internally and is disposed automatically on Dispose().
Platform Notes
Section titled “Platform Notes”Windows
Section titled “Windows”- Uses Win32
Shell_NotifyIcondirectly with a hidden message-only window forWM_TRAYICONcallbacks. - Right-click opens the menu via
TrackPopupMenuEx. - PNG bytes you pass to
SetIconare wrapped in a Vista-style ICO container at runtime so you don’t need a separate.icofile. - Badge composition and menu-item-icon HBITMAP conversion use
System.Drawing.Common, declared as a Windows-onlyPackageReference— no cost for other TFMs. - Accelerator dispatch uses
RegisterHotKeyagainst the host message window: hotkeys are process-global while your app is running. - Windows 11 hides new tray icons in the overflow flyout by default. Users have to drag yours into the always-visible area — document this for your users.
- Always call
Dispose()on app shutdown. Orphaned tray icons can persist in the Windows tray until reboot.
macOS (AppKit)
Section titled “macOS (AppKit)”- Native macOS app target (
net10.0-macos) usingNSStatusBar.SystemStatusBar.CreateStatusItem. - Click events route through
NSStatusItem.Button.Activated. Left vs right is distinguished by inspectingNSApplication.SharedApplication.CurrentEvent. - On macOS, primary (left) click opens the menu when one is assigned. To get a left-click event handler that doesn’t open the menu, don’t call
SetMenuand useShowMenu()from within your handler instead. - For a “menu bar app” (no Dock icon), add
LSUIElement = trueto your app’sInfo.plist. ShowNotificationusesNSUserNotificationCenter(the older API, still supported through current macOS).- Accelerator dispatch is the native
KeyEquivalent+ modifier mask path — AppKit handles it while the app is foreground.
MacCatalyst
Section titled “MacCatalyst”- Catalyst is UIKit and has no
NSStatusItem. The implementationdlopens/System/Library/Frameworks/AppKit.framework/AppKitat runtime and goes throughobjc_msgSend. - Menu callbacks ride a runtime-allocated
NSObjectsubclass (ShinyTrayCB) with[UnmanagedCallersOnly]trampolines — fully AOT-compatible. - Hardened sandboxes that disallow loading AppKit will reject the
dlopen. Normal Catalyst apps work fine. - All AppKit features (menu item icons, badges, notifications, accelerators) route through the same
objc_msgSendbridge.
- Hard dependency on
libayatana-appindicator3.so.1andlibgtk-3.so.0. Install via your distro:- Debian/Ubuntu:
apt install libayatana-appindicator3-1 libgtk-3-0 - Fedora:
dnf install libayatana-appindicator-gtk3 gtk3 - Arch:
pacman -S libayatana-appindicator gtk3
- Debian/Ubuntu:
- GNOME 40+ users need the AppIndicator extension installed (KDE works out of the box).
- The first tray icon initializes GTK (
gtk_init_check); subsequent icons reuse it. - The app-indicator API takes a file path, so the icon PNG is written to a temp file. Menu-item icons follow the same pattern, each in their own temp file that gets cleaned on menu rebuild and disposal.
ShowNotificationuseslibnotify— installlibnotify4if it’s not already present. The call no-ops gracefully if missing.- Truly global hotkeys require
libkeybinderwhich this library does not bind. The built-ingtk_widget_add_acceleratorpath is best-effort and only reliable while the indicator menu is open or focused.
Template Images on macOS
Section titled “Template Images on macOS”IsTemplateImage = true tells AppKit that the image is a template — it ignores the image’s colors and re-renders it in the system menu bar tint (black on light bars, white on dark bars, with selection highlighting). Pass a flat black-on-transparent PNG for this to look right. For non-template icons (full-color logos), leave it false.
icon.IsTemplateImage = true;icon.SetIcon(() => OpenStream("templates/menubar-icon.png"));Disposal
Section titled “Disposal”Tray icons live for the lifetime of your process by default. Always dispose them explicitly when you’re done — typically when the app is quitting, or when the user toggles the tray feature off.
public void OnAppShutdown(){ this.icon?.Dispose();}Forgetting to dispose can leave a ghost icon in the Windows tray that hangs around until the user hovers over it or signs out.
Related
Section titled “Related”- Use
Shiny.Maui.Controls.Toastwhen you want a rich in-app toast inside your MAUI window rather than the OS notification surface.