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.

From 2.1.0, TrayApp lives in composenativetray-app, not in the core tray artifact.

Add the dependency

build.gradle.kts
dependencies {
    implementation("dev.nucleusframework:composenativetray-app:2.1.6")
}

composenativetray-app pulls in composenativetray and the Nucleus Tao window backend. TrayApp is an extension on NucleusApplicationScope — launch with nucleusApplication { }.

Latest release: 2.1.6

Create a tray app

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

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import dev.nucleusframework.application.nucleusApplication
import dev.nucleusframework.composenativetray.trayapp.TrayApp

fun main() = nucleusApplication {
    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:

import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.foundation.layout.Column
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import dev.nucleusframework.application.nucleusApplication
import dev.nucleusframework.composenativetray.trayapp.TrayApp
import dev.nucleusframework.composenativetray.trayapp.TrayWindowDismissMode
import dev.nucleusframework.composenativetray.trayapp.rememberTrayAppState

fun main() = nucleusApplication {
    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:

import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Dashboard
import dev.nucleusframework.application.nucleusApplication
import dev.nucleusframework.composenativetray.trayapp.TrayApp
import kotlin.system.exitProcess

fun main() = nucleusApplication {
    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

On the Tao backend, do not set LSUIElement in Info.plist. Pass dockIconFollowsWindows = true to nucleusApplication instead:

fun main() = nucleusApplication(dockIconFollowsWindows = true) {
    TrayApp(
        icon = Icons.Default.Dashboard,
        tooltip = "Dashboard",
    ) {
        Text("Popup content")
    }
}

The app starts as an accessory (no Dock icon, no menu bar). A Dock tile appears only while at least one DecoratedWindow with hiddenFromDock = false is visible. TrayApp popups are standalone panels and never count, so a tray-only app stays out of the Dock. The flag is ignored off macOS and on the AWT backend. See Hiding from the taskbar/Dock.

API reference

TrayApp window options

ParameterDefaultNotes
windowSizeDpSize(300.dp, 200.dp)initial size
visibleOnStartfalseshow immediately
enterTransition / exitTransitionplatform defaultanimations
undecoratedtrueno chrome
resizablefalseuser resize
windowsTitle""window title (used on Linux and when decorated)
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

Notes

  • From 2.1.0, getTrayPosition() and TrayPosition live in composenativetray-app (dev.nucleusframework.composenativetray.trayapp). They need the Tao backend for screen geometry. Prefer TrayApp if you only need a popup next to the icon.
  • The popup is a standalone native panel, not a DecoratedWindow. File drops reach Compose dragAndDropTarget the same way as a regular window (Windows and macOS from 2.5.3, Linux from 2.5.4). Wheel and trackpad scroll uses the same AWT mapping as the main window (Windows from 2.5.2, macOS from 2.5.4, Linux from 2.5.6). A hidden Windows popup no longer submits GPU frames (2.5.2). A frame scheduled from inside a scene pass — a scrollbar drag subcomposing new lazy items, for example — is posted to the main queue instead of rendered inline, so it cannot re-enter measure and layout (2.5.7).
  • On macOS the panel is stationary and joins all spaces, so "Click wallpaper to reveal desktop" on Sonoma and later leaves it in place and TrayApp dismisses it itself (2.5.5). Clicks on the status item and on its context menu do not count as clicks outside the popup, so a right-click opens the menu instead of hiding the popup (2.5.5).

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.