System tray
A Compose-rendered tray icon and native context menu on macOS, Windows, and Linux — reactive, GraalVM-ready, no icon-export pipeline.
Building a tray app the traditional way means juggling .ico files, AppKit status items, AppIndicator XML menus, and reconciling three different click semantics. Tray() collapses all of that into a single composable: your icon is anything Compose can draw, your menu is a Kotlin DSL, your menu reacts to state.
Separate repository
System Tray ships from NucleusFramework/ComposeNativeTray with its own release cycle. The artifact is dev.nucleusframework:composenativetray.
TL;DR
- Render any
@Composable,ImageVector,Painter, orDrawableResourceas the tray icon — pixel-perfect at every DPI. - Reactive: change a
mutableStateOf, the icon and menu update. - Native menus on every platform: macOS NSStatusItem, Windows Shell_NotifyIcon, Linux StatusNotifierItem / AppIndicator.
- GraalVM Native Image compatible.
Install
dependencies {
implementation("dev.nucleusframework:composenativetray:<version>")
}Quickstart
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import dev.nucleusframework.tray.*
Tray(
icon = Icons.Default.Favorite,
tooltip = "My App",
primaryAction = { showWindow() },
) {
Item(label = "Show window") { showWindow() }
Divider()
Item(label = "Quit") { exitProcess(0) }
}How it works
Tray() is a composable. Its icon is rasterised from Compose every time the slot's state changes, then handed to the native tray API of the current OS. The menu lambda runs inside a DSL scope that re-evaluates on recomposition — adding, removing, or relabelling items costs nothing more than the usual Compose work.
The end result: the tray is a first-class participant in your Compose state tree. No ad hoc rebuild calls, no manual icon pipelines, no .ico/.png/.icns triple-maintenance.
Backed by NSStatusItem in the menu bar. Vector icons render at native DPI, with optional tinting via the tint parameter.
Backed by Shell_NotifyIcon. Vector input gets rasterised to a 32×32 .ico per scale factor. You can pass a hand-authored .ico via windowsIcon if you want exact control.
Uses StatusNotifierItem (KStatusNotifierItem). On stock GNOME this requires the AppIndicator extension; on Ubuntu it works out of the box.
StatusNotifierItem is native to Plasma — everything Just Works including hover tooltips and badges where Plasma supports them.
Icon sources
// ImageVector
Tray(icon = Icons.Default.Notifications, tint = Color.White, tooltip = "App") { /* … */ }
// Painter
Tray(icon = painterResource("icon.png"), tooltip = "App") { /* … */ }
// Compose Multiplatform DrawableResource
Tray(icon = Res.drawable.app_icon, tooltip = "App") { /* … */ }
// Fully custom — any composable
Tray(
iconContent = {
Box(Modifier.size(24.dp).background(Color.Red, CircleShape)) {
Text("3", color = Color.White, modifier = Modifier.align(Alignment.Center))
}
},
tooltip = "3 notifications",
) { /* … */ }Platform-specific icons
When you want a native .ico on Windows and a vector elsewhere:
Tray(
windowsIcon = painterResource("icon.ico"),
macLinuxIcon = Icons.Default.Notifications,
tint = Color.White,
tooltip = "My App",
) { /* menu */ }Primary action
primaryAction fires on left-click (macOS/Windows) or single click (Linux, desktop-dependent). Without it, the menu opens on every click.
Tray(
icon = Icons.Default.Favorite,
tooltip = "My App",
primaryAction = { showWindow() },
) {
Item(label = "Quit") { exitProcess(0) }
}Where to next
- Tray menu DSL — items, checkables, submenus, dividers, reactive composition.
- Tray apps — make the tray icon open a Compose popup window.