Nucleus
OS integration

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, or DrawableResource as 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.