Skip to content
Shiny.NET
Commercial SupportGuaranteed response times on Shiny bug fixes, straight from the maintainers. Plans from US$1,500 a year.See the plans

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:

Platform Details
Windows Shell_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
Linux libayatana-appindicator3 + GTK 3 (requires the system package to be installed)

This ships in the Shiny.Maui.Controls.Desktop add-on package — a desktop-only library that also contains the Docking host. Both features share the same TFM matrix (Windows + macOS AppKit + MacCatalyst + Linux GTK), so they live in one package rather than two.

There is no Blazor equivalent: a tray icon is a desktop OS concept.

  • NuGet downloads for Shiny.Maui.Controls.Desktop
Frameworks
.NET MAUI
Operating Systems
Windows
macOS
Linux
Changed files
  • MyApp/
MauiProgram.cs
MauiProgram.cs
1#if PLATFORM2using MyApp.Services;3​4namespace MyApp;5​6public static class MauiProgram7{8    public static MauiApp CreateMauiApp()9    {10        var builder = MauiApp.CreateBuilder();11​12        builder    13            .UseMauiApp<App>()14            .UseShiny()15            // desktop only - Android / iOS throw PlatformNotSupportedException when a tray icon is created16            .UseTrayIcon()17            .UseShinyDocking()18            .UseDesktopKeyboardShortcuts()19            .UseDesktopQuickEntry()   // borderless window over other apps from a global hotkey20            .UseFileDrop()21            .ConfigureFonts(fonts =>22            {23                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");24                fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");25            });26​27        // TODO: once you have localization, add these28        //builder.Services.AddLocalization();29        //builder.Services.AddStronglyTypedLocalizations();30        builder.Services.AddSingleton(TimeProvider.System);31#if DEBUG32        builder.Logging.SetMinimumLevel(LogLevel.Trace);33        builder.Logging.AddDebug();34#endif35​36        // source generated by Shiny.Extensions.DependencyInjection - registers every class marked [Singleton], [Scoped] or [Transient]37        builder.Services.AddGeneratedServices();38        // builder.Services.AddGeneratedAITools(); // https://shinylib.net/di/ai-tools/39        var app = builder.Build();40​41        return app;42    }43}44#endif
  • 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
  • Pills and progress rows: a coloured tag after an item’s label (TrayMenuItem.Pill) and labelled progress bars (TrayProgressItem) for usage meters, syncs and downloads. Progress rows update in place, even while the menu is open
  • Keyboard accelerator dispatch — TrayMenuItem.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.Desktop

In MauiProgram.cs:

using Shiny;
using Shiny.Maui.Controls.Desktop.TrayIcon; // ITrayIcon, ITrayIconFactory, TrayMenu, TrayMenuItem, ...
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.UseShinyDesktop();

UseShinyDesktop() is the one registration call for the whole Desktop package. It always registers the parts that do nothing until you use them: ITrayIconFactory, IGlobalHotKeyService, the keyboard shortcut key sources for macOS, Mac Catalyst and Linux, the docking registry, and the notch window service (macOS AppKit, Windows and Linux only). The parts that change how the app behaves are opt-in on the configuration:

builder.UseShinyDesktop(cfg => cfg
.UseQuickEntryWindow() // native always-on-top quick entry window
.UseFileDrop(o => o.AllowedExtensions.Add(".pdf")) // the window accepts dropped files
.AddDockPanel<ExplorerPanel>("explorer", "Explorer", "📁"));

Shared code needs no #if, except around cfg.ConfigureNotch(...), which only exists on macOS AppKit, Windows and Linux. It is safe to call UseShinyDesktop() more than once. The earlier UseTrayIcon(), UseDesktopQuickEntry(), UseDesktopNotch(), UseDesktopKeyboardShortcuts(), UseShinyDocking() and UseFileDrop() extensions have been removed.

The tray factory is 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;
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
TrayProgressItem .Progress(label, value, caption) or .Progress(TrayProgressItem) A labelled progress bar that can’t be clicked. See Pills and progress rows

All items expose IsEnabled, IsVisible, and Label. TrayMenuItem additionally exposes Icon and Accelerator — see the sections below for native behaviour.

A menu can show pills (a short coloured tag after an item’s label, such as “New” or “Pro”) and progress rows (a labelled bar for usage against a limit, a sync or a download). This is the usage-meter style of menu you see in apps like Claude Desktop.

The sample tray menu on macOS: items with keyboard accelerators, a checked Notifications item, live Session usage and Syncing library progress rows, Pro and New pills, and a Status submenu
var session = new TrayProgressItem("Session usage", 0.18, caption: "Resets in 4 hr 12 min");
var sync = new TrayProgressItem("Syncing library") { Tone = TrayTone.Success, Detail = "0 of 40" };
icon.SetMenu(TrayMenu.Build(b => b
.Progress(session)
.Progress(sync)
.Separator()
.Item(new TrayMenuItem("Upgrade plan", OpenBilling) { Pill = new TrayPill("Pro", TrayTone.Accent) })
.Item(new TrayMenuItem("What's new", OpenChangelog) { Pill = new TrayPill("New", TrayTone.Success) })));
// later, from any thread (a timer is fine): the row repaints in place, even while the menu is open
session.Value = 0.82;
session.Tone = TrayTone.Warning;
session.Caption = "Resets in 2 hr 3 min";
Member Description
TrayMenuItem.Pill TrayPill(Text, Tone) drawn after the label. A solid pill with white text, so it stays readable on light, dark and highlighted rows. Changing it rebuilds the menu
TrayProgressItem.Label Text on the left of the label row
TrayProgressItem.Value Fraction complete, 0–1. Values outside that range are clamped, and NaN counts as 0
TrayProgressItem.Detail Text on the right of the label row. null (the default) shows the percentage, e.g. 62%; "" shows nothing
TrayProgressItem.Caption Optional smaller line under the bar
TrayProgressItem.Tone Bar colour: Neutral, Accent (default), Success, Warning, Danger

Progress rows are informational: they never highlight and can’t be clicked. Changing Value, Label, Detail, Tone or the caption’s text repaints only that row, so keep a reference to the item and update it rather than rebuilding the menu. Adding or removing the caption changes the row’s height, so that one does rebuild the menu. Pills are supported on TrayMenuItem only (not on check items or submenu headers).

Platform Pills Progress rows
macOS (AppKit) Image in the item’s attributed title; the label still turns to the highlight colour when selected A custom NSView drawn with CoreGraphics; colours follow light/dark mode
MacCatalyst Same, via objc_msgSend Same, through an NSView subclass registered at runtime
Windows Owner-drawn row that keeps the visual-style background and hover highlight, the menu font, icon and accelerator Owner-drawn. An open menu is repainted with InvalidateRect
Linux Text: Upgrade plan [Pro] Text: Session usage ▰▰▰▰▰▰▱▱▱▱ 62%, with the caption as a greyed row beneath it

On Linux the desktop shell draws app-indicator menus itself from DBusMenu and keeps only labels, so nothing richer than text can get through.

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

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.

Member Description
SetIcon(Func<Stream>) Set the icon from a stream factory. PNG or ICO bytes both work — Windows auto-wraps PNG as ICO, and macOS scales whatever you hand it to the menu bar’s own height, so an ordinary full-size app icon is a fine source
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).

icon.Badge = unread.ToString(); // "3" overlay on Windows; "3" beside icon on macOS/Linux
icon.Badge = null; // clear
Platform Details
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.