Nucleus
OS integration

Tray-anchored apps

TrayApp anchors a Compose popup window to a system tray icon so you can build menu bar apps in Kotlin.

TrayApp anchors a full Compose window to a system tray icon: left-click the icon to open the window beside it, and click away to close it. It implements the menu bar app pattern used by tools such as Bartender, iStat Menus, and Hidden Bar.

Experimental

TrayApp is in alpha and the API may change. Opt in with @OptIn(ExperimentalTrayAppApi::class).

Add the dependency

TrayApp ships with composenativetray. If that dependency is on your classpath, no extra artifact is required.

Create a tray app

Pass an icon, a tooltip, a window size, and the Compose content to render in the popup:

@OptIn(ExperimentalTrayAppApi::class)
fun main() = application {
    TrayApp(
        icon = Icons.Default.Dashboard,
        tooltip = "Quick dashboard",
        windowSize = DpSize(300.dp, 400.dp),
    ) {
        Column(Modifier.fillMaxSize().padding(16.dp)) {
            Text("Dashboard", style = MaterialTheme.typography.h6)
            Spacer(Modifier.height(8.dp))
            Text("CPU: 42%")
            Text("RAM: 8.2 GB")
        }
    }
}

How it works

TrayApp is a tray icon plus a transparent, undecorated, always-on-top Compose window. The library tracks the tray icon's screen position and places the window next to it with the correct offset for each operating system.

Click-outside-to-dismiss is wired to the OS focus-loss event, so the popup closes the way native menu bar windows do on each desktop.

Control visibility and size with state

Hold a TrayAppState to show, hide, resize, or change the dismiss mode from your own code:

@OptIn(ExperimentalTrayAppApi::class)
fun main() = application {
    val state = rememberTrayAppState(
        initialWindowSize = DpSize(350.dp, 500.dp),
        initiallyVisible = false,
        initialDismissMode = TrayWindowDismissMode.AUTO,
    )

    TrayApp(
        icon = Icons.Default.Dashboard,
        tooltip = "Dashboard",
        state = state,
    ) {
        Column {
            Text("Dashboard")
            Button(onClick = { state.hide() }) { Text("Close") }
            Button(onClick = { state.setWindowSize(500.dp, 600.dp) }) { Text("Resize") }
        }
    }
}

Add a context menu

Left-click opens the popup; right-click opens a classic menu:

@OptIn(ExperimentalTrayAppApi::class)
fun main() = application {
    TrayApp(
        icon = Icons.Default.Dashboard,
        tooltip = "Dashboard",
        menu = {
            Item(label = "Settings") { openSettings() }
            Divider()
            Item(label = "Quit") { exitProcess(0) }
        },
    ) {
        Text("Popup content")
    }
}

Hide the Dock icon on macOS

A tray app usually does not need a Dock icon. On macOS, set LSUIElement = true in the app's Info.plist. The Nucleus Gradle plugin exposes macOS.infoPlist.extraKeysRawXml for this:

build.gradle.kts
nucleus {
    application {
        nativeDistributions {
            macOS {
                infoPlist {
                    extraKeysRawXml = """
                        <key>LSUIElement</key>
                        <true/>
                    """.trimIndent()
                }
            }
        }
    }
}

The app then runs with no Dock entry and no menu bar — only the tray icon.

API reference

TrayApp window options

ParameterDefaultNotes
windowSizeDpSize(300.dp, 200.dp)initial size
visibleOnStartfalseshow immediately
enterTransition / exitTransitionplatform defaultanimations
transparenttruetransparent background
undecoratedtrueno chrome
resizablefalseuser resize
horizontalOffset / verticalOffset0 / platform defaultnudge against the tray anchor

TrayAppState

APIDescription
isVisible: StateFlow<Boolean>current visibility
show() / hide() / toggle()imperative control
setWindowSize(size)resize on the fly
setDismissMode(mode)AUTO (click outside closes) or MANUAL
onVisibilityChanged(cb)observe transitions

What's next

  • System tray — the tray icon API that TrayApp builds on.
  • Tray menu DSL — build the right-click menu.
  • Single instance — re-launching from a .app re-shows the popup instead of starting a second process.