Skip to content
Introducing AI Conversations: Natural Language Interaction for Your Apps! Learn More

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:

  • WindowsShell_NotifyIcon (Win32) with a hidden message-only window for click routing
  • macOS (AppKit)NSStatusBar.SystemStatusBar via the native net10.0-macos bindings
  • MacCatalyst — bridges to AppKit at runtime through the Objective-C runtime
  • Linuxlibayatana-appindicator3 + GTK 3 (requires the system package to be installed)

This ships as a separate packageShiny.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.

  • NuGet downloads for Shiny.Maui.Controls.TrayIcon
Frameworks
.NET MAUI
Operating Systems
Windows
macOS
Linux
  • 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) — a Func<Stream> per item, native rendering on all four platforms
  • Keyboard accelerator dispatchTrayMenuItem.Accelerator (e.g. "Ctrl+S") is registered with the OS so the shortcut actually fires
  • Badge / overlay numbers (Badge property) — composited onto the icon on Windows, displayed beside the icon on macOS / Linux
  • Balloon / toast notifications (ShowNotification(title, message)) — Windows NIF_INFO, macOS / Catalyst NSUserNotificationCenter, Linux libnotify
  • 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
Terminal window
dotnet add package Shiny.Maui.Controls.TrayIcon

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

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.

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;
TypeBuilder methodNotes
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.

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
}
PlatformMechanism
WindowsSetMenuItemInfoW + 32bpp pre-multiplied alpha HBITMAP (auto-sized to SM_CXMENUCHECK)
macOSNSMenuItem.Image (16×16, respects template-image semantics)
MacCatalystSame as macOS via objc_msgSend setImage: + setSize:
Linuxgtk_image_menu_item_* — deprecated in GTK 3.10 but still functional. Some GNOME hosts hide menu-item icons by policy

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"
PlatformMechanismScope
WindowsRegisterHotKey on the tray’s hidden message-only window, dispatched via WM_HOTKEYGlobal system hotkey while the process is running
macOS (AppKit)NSMenuItem.KeyEquivalent + KeyEquivalentModifierMaskApp-wide while foreground
MacCatalystSame as AppKit via objc_msgSendApp-wide while foreground
Linuxgtk_widget_add_accelerator on a GtkAccelGroup attached to the menuBest-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.

MemberDescription
SetIcon(Func<Stream>)Set the icon from a stream factory. PNG or ICO bytes both work — Windows auto-wraps PNG as ICO
TooltipHover tooltip (Windows / macOS) or accessible description (Linux)
TitleOptional text label shown beside or instead of the icon on macOS and Linux. Ignored on Windows
BadgeOptional string composited onto the icon (Windows) or shown beside it (macOS / Linux). Set to null to clear
IsVisibleShow or hide without disposing
IsTemplateImagemacOS 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
IsAnimatingtrue while an animation is running
PrimaryClickLeft-click. On macOS, primary-click already opens the menu when one is assigned
SecondaryClickRight-click / control-click
DoubleClickWindows + 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).

icon.Badge = unread.ToString(); // "3" overlay on Windows; "3" beside icon on macOS/Linux
icon.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 uses System.Drawing.Common, pulled in only for the Windows TFM.
  • macOS / MacCatalyst: the badge string is appended to the status button title (combined with Title if both are set).
  • Linux: the badge is set on app_indicator_set_label alongside Title.
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.

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));
// later
icon.StopAnimation(); // restores the last static icon set via SetIcon

The timer is owned internally and is disposed automatically on Dispose().

  • Uses Win32 Shell_NotifyIcon directly with a hidden message-only window for WM_TRAYICON callbacks.
  • Right-click opens the menu via TrackPopupMenuEx.
  • PNG bytes you pass to SetIcon are wrapped in a Vista-style ICO container at runtime so you don’t need a separate .ico file.
  • Badge composition and menu-item-icon HBITMAP conversion use System.Drawing.Common, declared as a Windows-only PackageReference — no cost for other TFMs.
  • Accelerator dispatch uses RegisterHotKey against 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.
  • Native macOS app target (net10.0-macos) using NSStatusBar.SystemStatusBar.CreateStatusItem.
  • Click events route through NSStatusItem.Button.Activated. Left vs right is distinguished by inspecting NSApplication.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 SetMenu and use ShowMenu() from within your handler instead.
  • For a “menu bar app” (no Dock icon), add LSUIElement = true to your app’s Info.plist.
  • ShowNotification uses NSUserNotificationCenter (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.
  • Catalyst is UIKit and has no NSStatusItem. The implementation dlopens /System/Library/Frameworks/AppKit.framework/AppKit at runtime and goes through objc_msgSend.
  • Menu callbacks ride a runtime-allocated NSObject subclass (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_msgSend bridge.
  • Hard dependency on libayatana-appindicator3.so.1 and libgtk-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
  • 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.
  • ShowNotification uses libnotify — install libnotify4 if it’s not already present. The call no-ops gracefully if missing.
  • Truly global hotkeys require libkeybinder which this library does not bind. The built-in gtk_widget_add_accelerator path is best-effort and only reliable while the indicator menu is open or focused.

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"));

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.

  • Use Shiny.Maui.Controls.Toast when you want a rich in-app toast inside your MAUI window rather than the OS notification surface.