Nucleus
OS integration

Tray-anchored apps

A Compose popup window anchored to the tray icon — Bartender / iStat-style menu bar apps, in Kotlin.

TrayApp turns a tray icon into a full Compose window that pops up beside it — the menu bar app pattern made famous by Bartender, iStat Menus, Hidden Bar. Click the icon, a window slides in. Click away, it slides out.

Experimental

TrayApp is in alpha. The API may move. Opt in with @OptIn(ExperimentalTrayAppApi::class).

TL;DR

  • The entire popup is a Compose canvas — anything you can render, you can anchor.
  • Visibility, size, dismiss mode controllable via TrayAppState.
  • Can be combined with a right-click context menu.
  • Smooth enter/exit animations with platform defaults.

Install

TrayApp ships with composenativetray — no extra dependency.

Quickstart

@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 parks the window next to it with the right offset for each OS. Click-outside-to-dismiss is wired into the OS focus loss event, so the popup feels native on every desktop.

State management

@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") }
        }
    }
}

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

With 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")
    }
}

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

Tray-only lifecycle (macOS)

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

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

The app will run with no Dock entry and no menu bar — just the tray icon.

See also