
Enables creation of cross-platform system tray applications with native support, offering dynamic menu items, submenus, HDPI improvements, single instance management, and tray position detection using a DSL-style syntax.
Compose Native Tray is a modern Kotlin library for creating applications with system tray icons, offering native support for Linux, Windows, and macOS. It uses an intuitive Kotlin DSL syntax and fixes issues with the standard Compose for Desktop solution.
This library was created to solve several limitations of the standard Compose for Desktop solution:
![]() Windows |
![]() macOS |
![]() Ubuntu GNOME |
![]() Ubuntu KDE |
Add the dependency to your build.gradle.kts:
dependencies {
// System tray icon + menu (lightweight — no windowing backend pulled in)
implementation("dev.nucleusframework:composenativetray:<version>")
// Only if you use TrayApp (the tray + anchored popup window API).
// Pulls in the Nucleus application / decorated-window-tao backend.
implementation("dev.nucleusframework:composenativetray-app:<version>")
}Since
2.1.0the library is split in two so apps that only need a tray icon don't pull in the heavierdecorated-window-taowindowing backend (see #418).Trayand the menu DSL live incomposenativetrayand work in any Compose Desktopapplication { }— no Nucleus application scope required.TrayApplives incomposenativetray-appand needsnucleusApplication { }. Add the second artifact only when you useTrayApp.
Minimal example to create a system tray icon with menu. Tray is a regular composable — it
works inside Compose Desktop's application { … } or Nucleus' nucleusApplication { … }:
application {
Tray(
icon = Icons.Default.Favorite,
tooltip = "My Application"
) {
Item(label = "Settings") {
println("Settings opened")
}
Divider()
Item(label = "Exit") {
exitProcess(0)
}
}
}💡 Recommendation: It is highly recommended to check out the demo examples in the project's
demodirectory. These examples showcase various implementation patterns and features that will help you better understand how to use the library effectively.Notable demos:
- DemoWithDrawableResources.kt – shows using DrawableResource directly for Tray and menu icons
- DemoWithPainter.kt – demonstrates using a
painterResourceicon- DemoWithoutContextMenu.kt – minimalist tray with primary action only
- TrayAppDemo.kt – the full TrayApp (tray + popup window) example
Tray(
icon = Res.drawable.myIcon, // org.jetbrains.compose.resources.DrawableResource
tooltip = "My Application"
) { /* menu */ }Requires compose.components.resources in your project. In this library it's already included; in your app add: implementation(compose.components.resources)
Tray(
icon = Icons.Default.Favorite,
tint = null, // Optional: if null, the tint automatically adapts (white in dark mode, black in light mode) according to the isMenuBarInDarkMode() API
tooltip = "My Application"
) { /* menu */ }Tray(
icon = painterResource(Res.drawable.myIcon),
tooltip = "My Application"
) { /* menu */ }Tray(
iconContent = {
Canvas(modifier = Modifier.fillMaxSize()) { // Important to use fillMaxSize()!
// A simple red circle as an icon
drawCircle(
color = Color.Red,
radius = size.minDimension / 2,
center = center
)
}
},
tooltip = "My Application"
) { /* menu */ }
⚠️ Important: Always useModifier.fillMaxSize()withiconContentfor proper icon rendering.
This approach allows respecting the design conventions of each platform:
val windowsIcon = painterResource(Res.drawable.myIcon)
val macLinuxIcon = Icons.Default.Favorite
Tray(
windowsIcon = windowsIcon, // Windows: full colored icon
macLinuxIcon = macLinuxIcon, // macOS/Linux: adaptive icon
tooltip = "My Application"
) { /* menu */ }💡 Note: If no tint is specified, ImageVectors are automatically tinted white (dark mode) or black (light mode) based on the theme.
Define an action for clicking on the icon. The behavior varies by platform:
Tray(
icon = Icons.Default.Favorite,
tooltip = "My Application",
primaryAction = {
println("Icon clicked!")
// Open a window, display a menu, etc.
}
) { /* menu */ }Important note: It's not mandatory to create a context menu. You can use only an icon in the tray with a primary action (left-click) to restore your application, as shown in the
DemoWithoutContextMenu.ktexample. This minimalist approach is perfect for simple applications that only need a restore function.
The menu uses an intuitive DSL syntax with several types of elements:
Tray(/* configuration */) {
// Simple item with icon
Item(label = "Open", icon = Icons.Default.OpenInNew) {
// Click action
}
// Item with custom icon via iconContent
Item(
label = "Custom",
iconContent = {
Icon(
Icons.Default.Star,
contentDescription = null,
tint = Color.Yellow,
modifier = Modifier.fillMaxSize() // Important!
)
}
) { }
// Checkable item
CheckableItem(
label = "Dark Mode",
icon = Icons.Default.DarkMode,
checked = isDarkMode,
onCheckedChange = { isDarkMode = it }
)
// Submenu
SubMenu(label = "Options", icon = Icons.Default.Settings) {
Item(label = "Option 1") { }
Item(label = "Option 2") { }
// Nested submenus supported!
SubMenu(label = "Advanced") {
Item(label = "Advanced Option") { }
}
}
// Visual separator
Divider()
// Disabled item - the isEnabled property controls whether the item can be clicked
Item(label = "Version 1.0.0", isEnabled = false)
// Enabled item (isEnabled is true by default)
Item(label = "Help", isEnabled = true) {
// This action will be executed when clicked
}
// Exit properly
Item(label = "Exit") {
dispose() // Removes the system tray icon
exitProcess(0)
}
}You can now pass DrawableResource directly to menu builders:
Tray(icon = Res.drawable.app_icon, tooltip = "App") {
SubMenu(label = "With icons", icon = Res.drawable.gear) {
Item(label = "Action 1", icon = Res.drawable.star) { /* ... */ }
Item(label = "Action 2", icon = Res.drawable.star) { /* ... */ }
}
Divider()
CheckableItem(
label = "Enabled",
icon = Res.drawable.check,
checked = true,
onCheckedChange = { /* ... */ }
)
}See demo/DemoWithDrawableResources.kt for a complete example.
When using painterResource with menu items, declare it in the composable context:
application {
val advancedIcon = painterResource(Res.drawable.advanced) // ✅ Correct
Tray(/* config */) {
SubMenu(
label = "Advanced",
icon = advancedIcon // Use the variable
) { /* items */ }
}
}The library supports Compose recomposition for all aspects of the system menu:
// Example 1: Dynamic display/hiding of the icon
var isWindowVisible by remember { mutableStateOf(true) }
// The icon only appears when the window is hidden
if (!isWindowVisible) {
Tray(
icon = Icons.Default.Favorite,
tooltip = "Click to restore"
) {
Item(label = "Restore") {
isWindowVisible = true
}
}
}
// Example 2: Fully reactive menu
application {
var darkMode by remember { mutableStateOf(false) }
var showAdvancedOptions by remember { mutableStateOf(false) }
var notificationsEnabled by remember { mutableStateOf(true) }
var isConfigAvailable by remember { mutableStateOf(false) }
Tray(
// The icon changes based on the mode
icon = if (darkMode) Icons.Default.DarkMode else Icons.Default.LightMode,
tooltip = "My Application"
) {
// Item with reactive label and icon
Item(
label = if (darkMode) "Switch to Light Mode" else "Switch to Dark Mode",
icon = if (darkMode) Icons.Default.LightMode else Icons.Default.DarkMode
) {
darkMode = !darkMode
}
// Reactive checkable item
CheckableItem(
label = "Notifications",
checked = notificationsEnabled,
onCheckedChange = { notificationsEnabled = it }
)
// Conditional display of items
if (showAdvancedOptions) {
Divider()
SubMenu(label = "Advanced Options") {
// Item with dynamically changing isEnabled property
Item(
label = "Configuration",
isEnabled = isConfigAvailable
) { /* action */ }
// This item enables the Configuration option when clicked
Item(label = "Check Configuration Availability") {
isConfigAvailable = true
}
Item(label = "Diagnostics") { /* action */ }
}
}
Divider()
// Visibility control
Item(
label = if (showAdvancedOptions) "Hide Advanced Options" else "Show Advanced Options"
) {
showAdvancedOptions = !showAdvancedOptions
}
}
}All menu properties (icon, labels, states, item visibility) are reactive and update automatically when application states change, without requiring manual recreation of the menu.
Prevent multiple instances of your application:
The single instance manager combined with the primary action (left-click) is particularly useful for restoring a minimized application in the tray rather than opening a new instance. This improves the user experience by:
Single instance is enabled by default in nucleusApplication; pass enableSingleInstance = false
to opt out. When a second launch is detected, the already-running instance is notified through
SingleInstanceRestoreEffect — restore your window (or re-open the tray popup) there instead of
starting a new process:
import dev.nucleusframework.application.SingleInstanceRestoreEffect
nucleusApplication(enableSingleInstance = true) { // true is the default
var isWindowVisible by remember { mutableStateOf(true) }
// Runs in the already-running instance each time the app is launched again.
SingleInstanceRestoreEffect {
isWindowVisible = true // bring the existing window / tray popup back
}
// ... Tray / TrayApp / windows
}See TrayAppDemo.kt for a working example (a second launch re-opens the tray popup).
To react to a deep link the OS hands to the app (including one that arrives on a second launch while single instance is enabled), register a handler on the application scope:
nucleusApplication {
onDeepLink { uri ->
// handle the incoming URI (navigate, restore state, …)
}
// ... Tray / TrayApp / windows
}Tray positioning needs screen geometry (the Tao backend), so these helpers live in the
composenativetray-app artifact (package dev.nucleusframework.composenativetray.trayapp) —
the same one that provides TrayApp, which uses them internally.
getTrayPosition() tells you which screen corner the tray icon sits in:
val corner: TrayPosition = getTrayPosition() // TOP_LEFT / TOP_RIGHT / BOTTOM_LEFT / BOTTOM_RIGHTgetTrayWindowPosition(...) computes a precise window position anchored to the tray icon:
val windowPosition = getTrayWindowPosition(windowWidth = 800, windowHeight = 600)The window is horizontally centered on the icon and vertically anchored to the top or bottom of the
screen depending on where the tray lives. For a ready-made tray + popup window, prefer TrayApp.
Automatically adapt your icons to the theme:
val isMenuBarDark = isMenuBarInDarkMode()
Tray(
iconContent = {
Icon(
Icons.Default.Favorite,
contentDescription = "",
tint = if (isMenuBarDark) Color.White else Color.Black,
modifier = Modifier.fillMaxSize()
)
},
tooltip = "My Application"
) { /* menu */ }Platform Behavior:
💡 macOS Note: The system tray icon follows the menu bar color (based on the wallpaper), but the menu item icons follow the system theme.
Two options for customizing rendering:
// Option 1: Optimized for the current OS
Tray(
icon = Icons.Default.Favorite,
iconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(
sceneWidth = 192, // Compose scene width
sceneHeight = 192, // Compose scene height
density = Density(2f) // Rendering density
)
) { /* menu */ }
// Option 2: Without forced scaling
Tray(
icon = Icons.Default.Favorite,
iconRenderProperties = IconRenderProperties.withoutScalingAndAliasing(
sceneWidth = 192,
sceneHeight = 192,
density = Density(2f)
)
) { /* menu */ }By default, icons are optimized by OS: 32x32px (Windows), 44x44px (macOS), 24x24px (Linux).
When building a release package (e.g. via packageReleaseUberJarForCurrentOS), you need to add ProGuard rules to keep JNA and library classes since this library relies on reflection. Without these rules, the tray icon may render incorrectly (semi-transparent background, broken click actions, wrong tooltip).
Add the following to your ProGuard rules file:
-keep class com.sun.jna.** { *; }
-keep class dev.nucleusframework.composenativetray.** { *; }
Status: Alpha — The core API is functional on Windows, macOS, and Linux, but breaking changes may still occur. Feedback and bug reports are welcome!
TrayApp gives your desktop app a system‑tray/menu‑bar icon and a tiny popup window for quick actions. It's perfect for quick toggles, mini dashboards, and "control center" UIs.
Works on Windows, macOS, and Linux. Smooth fade animations, smart positioning near the tray, and a simple API so you stay productive.
📦 Dependency:
TrayApplives in a separate artifact so basic-tray users don't pull in the windowing backend. Add it in addition to the core:implementation("dev.nucleusframework:composenativetray-app:<version>")It requires the Nucleus Tao backend — launch your app with
nucleusApplication { … }.TrayApp,TrayAppState,rememberTrayAppStateandTrayWindowDismissModelive in thedev.nucleusframework.composenativetray.trayapppackage. See theTrayAppDemoin thedemomodule for a complete example.
setWindowSize(...)
nucleusApplication {
val trayAppState = rememberTrayAppState(
initialWindowSize = DpSize(300.dp, 420.dp),
initiallyVisible = true // default is false
// initialDismissMode defaults to TrayWindowDismissMode.AUTO
)
TrayApp(
state = trayAppState,
icon = Icons.Default.Dashboard, // required (or Painter / platform-specific overloads)
tooltip = "My Tray App", // required
// Optional visual controls (defaults shown below)
undecorated = true, // default = true
resizable = false, // default = false
windowsTitle = "My Tray Popup", // default = "" — recommended (esp. on Linux & when undecorated=false)
windowIcon = null, // default = null — set your app icon; important on Linux & when undecorated=false
menu = { // optional (default = null)
Item("Toggle popup") { trayAppState.toggle() }
Divider()
Item("Quit") { exitApplication() }
}
) {
// Your Compose UI (DialogWindowScope receiver)
MaterialTheme {
Text("Quick Settings")
Button(onClick = { trayAppState.hide() }) { Text("Close") }
}
}
}trayAppState.show()
trayAppState.hide()
trayAppState.toggle()trayAppState.setWindowSize(400.dp, 600.dp)
// or
trayAppState.setWindowSize(DpSize(250.dp, 350.dp))// AUTO (default): closes when user clicks outside or focus is lost
val state = rememberTrayAppState(initialDismissMode = TrayWindowDismissMode.AUTO)
// MANUAL: you decide when to hide
LaunchedEffect(Unit) {
state.setDismissMode(TrayWindowDismissMode.MANUAL)
}TrayApp(
state = trayAppState,
icon = Icons.Default.Settings,
tooltip = "Quick Settings",
menu = {
val isVisible by trayAppState.isVisible.collectAsState()
Item(if (isVisible) "Hide" else "Show") { trayAppState.toggle() }
SubMenu("Size") {
Item("250×350") { trayAppState.setWindowSize(250.dp, 350.dp) }
Item("350×500") { trayAppState.setWindowSize(350.dp, 500.dp) }
Item("450×600") { trayAppState.setWindowSize(450.dp, 600.dp) }
}
}
) {
// your content
}windowsTitle and windowIcon. Even with undecorated UIs, Linux desktop environments often show a dock/taskbar entry; providing a title/icon prevents generic placeholders and improves discoverability.| App | Description |
|---|---|
| AB Download Manager | A desktop download manager |
| RIFT Intel Fusion Tool | A tool for EVE Online |
If your app uses Compose Native Tray, feel free to open a PR to add it here!
This library is licensed under the MIT License. The Linux module uses Apache 2.0
Contributions are welcome! Feel free to:
Developed and maintained by Elie Gambache with the goal of providing a modern, cross-platform solution for system tray icons in Kotlin.
Compose Native Tray is a modern Kotlin library for creating applications with system tray icons, offering native support for Linux, Windows, and macOS. It uses an intuitive Kotlin DSL syntax and fixes issues with the standard Compose for Desktop solution.
This library was created to solve several limitations of the standard Compose for Desktop solution:
![]() Windows |
![]() macOS |
![]() Ubuntu GNOME |
![]() Ubuntu KDE |
Add the dependency to your build.gradle.kts:
dependencies {
// System tray icon + menu (lightweight — no windowing backend pulled in)
implementation("dev.nucleusframework:composenativetray:<version>")
// Only if you use TrayApp (the tray + anchored popup window API).
// Pulls in the Nucleus application / decorated-window-tao backend.
implementation("dev.nucleusframework:composenativetray-app:<version>")
}Since
2.1.0the library is split in two so apps that only need a tray icon don't pull in the heavierdecorated-window-taowindowing backend (see #418).Trayand the menu DSL live incomposenativetrayand work in any Compose Desktopapplication { }— no Nucleus application scope required.TrayApplives incomposenativetray-appand needsnucleusApplication { }. Add the second artifact only when you useTrayApp.
Minimal example to create a system tray icon with menu. Tray is a regular composable — it
works inside Compose Desktop's application { … } or Nucleus' nucleusApplication { … }:
application {
Tray(
icon = Icons.Default.Favorite,
tooltip = "My Application"
) {
Item(label = "Settings") {
println("Settings opened")
}
Divider()
Item(label = "Exit") {
exitProcess(0)
}
}
}💡 Recommendation: It is highly recommended to check out the demo examples in the project's
demodirectory. These examples showcase various implementation patterns and features that will help you better understand how to use the library effectively.Notable demos:
- DemoWithDrawableResources.kt – shows using DrawableResource directly for Tray and menu icons
- DemoWithPainter.kt – demonstrates using a
painterResourceicon- DemoWithoutContextMenu.kt – minimalist tray with primary action only
- TrayAppDemo.kt – the full TrayApp (tray + popup window) example
Tray(
icon = Res.drawable.myIcon, // org.jetbrains.compose.resources.DrawableResource
tooltip = "My Application"
) { /* menu */ }Requires compose.components.resources in your project. In this library it's already included; in your app add: implementation(compose.components.resources)
Tray(
icon = Icons.Default.Favorite,
tint = null, // Optional: if null, the tint automatically adapts (white in dark mode, black in light mode) according to the isMenuBarInDarkMode() API
tooltip = "My Application"
) { /* menu */ }Tray(
icon = painterResource(Res.drawable.myIcon),
tooltip = "My Application"
) { /* menu */ }Tray(
iconContent = {
Canvas(modifier = Modifier.fillMaxSize()) { // Important to use fillMaxSize()!
// A simple red circle as an icon
drawCircle(
color = Color.Red,
radius = size.minDimension / 2,
center = center
)
}
},
tooltip = "My Application"
) { /* menu */ }
⚠️ Important: Always useModifier.fillMaxSize()withiconContentfor proper icon rendering.
This approach allows respecting the design conventions of each platform:
val windowsIcon = painterResource(Res.drawable.myIcon)
val macLinuxIcon = Icons.Default.Favorite
Tray(
windowsIcon = windowsIcon, // Windows: full colored icon
macLinuxIcon = macLinuxIcon, // macOS/Linux: adaptive icon
tooltip = "My Application"
) { /* menu */ }💡 Note: If no tint is specified, ImageVectors are automatically tinted white (dark mode) or black (light mode) based on the theme.
Define an action for clicking on the icon. The behavior varies by platform:
Tray(
icon = Icons.Default.Favorite,
tooltip = "My Application",
primaryAction = {
println("Icon clicked!")
// Open a window, display a menu, etc.
}
) { /* menu */ }Important note: It's not mandatory to create a context menu. You can use only an icon in the tray with a primary action (left-click) to restore your application, as shown in the
DemoWithoutContextMenu.ktexample. This minimalist approach is perfect for simple applications that only need a restore function.
The menu uses an intuitive DSL syntax with several types of elements:
Tray(/* configuration */) {
// Simple item with icon
Item(label = "Open", icon = Icons.Default.OpenInNew) {
// Click action
}
// Item with custom icon via iconContent
Item(
label = "Custom",
iconContent = {
Icon(
Icons.Default.Star,
contentDescription = null,
tint = Color.Yellow,
modifier = Modifier.fillMaxSize() // Important!
)
}
) { }
// Checkable item
CheckableItem(
label = "Dark Mode",
icon = Icons.Default.DarkMode,
checked = isDarkMode,
onCheckedChange = { isDarkMode = it }
)
// Submenu
SubMenu(label = "Options", icon = Icons.Default.Settings) {
Item(label = "Option 1") { }
Item(label = "Option 2") { }
// Nested submenus supported!
SubMenu(label = "Advanced") {
Item(label = "Advanced Option") { }
}
}
// Visual separator
Divider()
// Disabled item - the isEnabled property controls whether the item can be clicked
Item(label = "Version 1.0.0", isEnabled = false)
// Enabled item (isEnabled is true by default)
Item(label = "Help", isEnabled = true) {
// This action will be executed when clicked
}
// Exit properly
Item(label = "Exit") {
dispose() // Removes the system tray icon
exitProcess(0)
}
}You can now pass DrawableResource directly to menu builders:
Tray(icon = Res.drawable.app_icon, tooltip = "App") {
SubMenu(label = "With icons", icon = Res.drawable.gear) {
Item(label = "Action 1", icon = Res.drawable.star) { /* ... */ }
Item(label = "Action 2", icon = Res.drawable.star) { /* ... */ }
}
Divider()
CheckableItem(
label = "Enabled",
icon = Res.drawable.check,
checked = true,
onCheckedChange = { /* ... */ }
)
}See demo/DemoWithDrawableResources.kt for a complete example.
When using painterResource with menu items, declare it in the composable context:
application {
val advancedIcon = painterResource(Res.drawable.advanced) // ✅ Correct
Tray(/* config */) {
SubMenu(
label = "Advanced",
icon = advancedIcon // Use the variable
) { /* items */ }
}
}The library supports Compose recomposition for all aspects of the system menu:
// Example 1: Dynamic display/hiding of the icon
var isWindowVisible by remember { mutableStateOf(true) }
// The icon only appears when the window is hidden
if (!isWindowVisible) {
Tray(
icon = Icons.Default.Favorite,
tooltip = "Click to restore"
) {
Item(label = "Restore") {
isWindowVisible = true
}
}
}
// Example 2: Fully reactive menu
application {
var darkMode by remember { mutableStateOf(false) }
var showAdvancedOptions by remember { mutableStateOf(false) }
var notificationsEnabled by remember { mutableStateOf(true) }
var isConfigAvailable by remember { mutableStateOf(false) }
Tray(
// The icon changes based on the mode
icon = if (darkMode) Icons.Default.DarkMode else Icons.Default.LightMode,
tooltip = "My Application"
) {
// Item with reactive label and icon
Item(
label = if (darkMode) "Switch to Light Mode" else "Switch to Dark Mode",
icon = if (darkMode) Icons.Default.LightMode else Icons.Default.DarkMode
) {
darkMode = !darkMode
}
// Reactive checkable item
CheckableItem(
label = "Notifications",
checked = notificationsEnabled,
onCheckedChange = { notificationsEnabled = it }
)
// Conditional display of items
if (showAdvancedOptions) {
Divider()
SubMenu(label = "Advanced Options") {
// Item with dynamically changing isEnabled property
Item(
label = "Configuration",
isEnabled = isConfigAvailable
) { /* action */ }
// This item enables the Configuration option when clicked
Item(label = "Check Configuration Availability") {
isConfigAvailable = true
}
Item(label = "Diagnostics") { /* action */ }
}
}
Divider()
// Visibility control
Item(
label = if (showAdvancedOptions) "Hide Advanced Options" else "Show Advanced Options"
) {
showAdvancedOptions = !showAdvancedOptions
}
}
}All menu properties (icon, labels, states, item visibility) are reactive and update automatically when application states change, without requiring manual recreation of the menu.
Prevent multiple instances of your application:
The single instance manager combined with the primary action (left-click) is particularly useful for restoring a minimized application in the tray rather than opening a new instance. This improves the user experience by:
Single instance is enabled by default in nucleusApplication; pass enableSingleInstance = false
to opt out. When a second launch is detected, the already-running instance is notified through
SingleInstanceRestoreEffect — restore your window (or re-open the tray popup) there instead of
starting a new process:
import dev.nucleusframework.application.SingleInstanceRestoreEffect
nucleusApplication(enableSingleInstance = true) { // true is the default
var isWindowVisible by remember { mutableStateOf(true) }
// Runs in the already-running instance each time the app is launched again.
SingleInstanceRestoreEffect {
isWindowVisible = true // bring the existing window / tray popup back
}
// ... Tray / TrayApp / windows
}See TrayAppDemo.kt for a working example (a second launch re-opens the tray popup).
To react to a deep link the OS hands to the app (including one that arrives on a second launch while single instance is enabled), register a handler on the application scope:
nucleusApplication {
onDeepLink { uri ->
// handle the incoming URI (navigate, restore state, …)
}
// ... Tray / TrayApp / windows
}Tray positioning needs screen geometry (the Tao backend), so these helpers live in the
composenativetray-app artifact (package dev.nucleusframework.composenativetray.trayapp) —
the same one that provides TrayApp, which uses them internally.
getTrayPosition() tells you which screen corner the tray icon sits in:
val corner: TrayPosition = getTrayPosition() // TOP_LEFT / TOP_RIGHT / BOTTOM_LEFT / BOTTOM_RIGHTgetTrayWindowPosition(...) computes a precise window position anchored to the tray icon:
val windowPosition = getTrayWindowPosition(windowWidth = 800, windowHeight = 600)The window is horizontally centered on the icon and vertically anchored to the top or bottom of the
screen depending on where the tray lives. For a ready-made tray + popup window, prefer TrayApp.
Automatically adapt your icons to the theme:
val isMenuBarDark = isMenuBarInDarkMode()
Tray(
iconContent = {
Icon(
Icons.Default.Favorite,
contentDescription = "",
tint = if (isMenuBarDark) Color.White else Color.Black,
modifier = Modifier.fillMaxSize()
)
},
tooltip = "My Application"
) { /* menu */ }Platform Behavior:
💡 macOS Note: The system tray icon follows the menu bar color (based on the wallpaper), but the menu item icons follow the system theme.
Two options for customizing rendering:
// Option 1: Optimized for the current OS
Tray(
icon = Icons.Default.Favorite,
iconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(
sceneWidth = 192, // Compose scene width
sceneHeight = 192, // Compose scene height
density = Density(2f) // Rendering density
)
) { /* menu */ }
// Option 2: Without forced scaling
Tray(
icon = Icons.Default.Favorite,
iconRenderProperties = IconRenderProperties.withoutScalingAndAliasing(
sceneWidth = 192,
sceneHeight = 192,
density = Density(2f)
)
) { /* menu */ }By default, icons are optimized by OS: 32x32px (Windows), 44x44px (macOS), 24x24px (Linux).
When building a release package (e.g. via packageReleaseUberJarForCurrentOS), you need to add ProGuard rules to keep JNA and library classes since this library relies on reflection. Without these rules, the tray icon may render incorrectly (semi-transparent background, broken click actions, wrong tooltip).
Add the following to your ProGuard rules file:
-keep class com.sun.jna.** { *; }
-keep class dev.nucleusframework.composenativetray.** { *; }
Status: Alpha — The core API is functional on Windows, macOS, and Linux, but breaking changes may still occur. Feedback and bug reports are welcome!
TrayApp gives your desktop app a system‑tray/menu‑bar icon and a tiny popup window for quick actions. It's perfect for quick toggles, mini dashboards, and "control center" UIs.
Works on Windows, macOS, and Linux. Smooth fade animations, smart positioning near the tray, and a simple API so you stay productive.
📦 Dependency:
TrayApplives in a separate artifact so basic-tray users don't pull in the windowing backend. Add it in addition to the core:implementation("dev.nucleusframework:composenativetray-app:<version>")It requires the Nucleus Tao backend — launch your app with
nucleusApplication { … }.TrayApp,TrayAppState,rememberTrayAppStateandTrayWindowDismissModelive in thedev.nucleusframework.composenativetray.trayapppackage. See theTrayAppDemoin thedemomodule for a complete example.
setWindowSize(...)
nucleusApplication {
val trayAppState = rememberTrayAppState(
initialWindowSize = DpSize(300.dp, 420.dp),
initiallyVisible = true // default is false
// initialDismissMode defaults to TrayWindowDismissMode.AUTO
)
TrayApp(
state = trayAppState,
icon = Icons.Default.Dashboard, // required (or Painter / platform-specific overloads)
tooltip = "My Tray App", // required
// Optional visual controls (defaults shown below)
undecorated = true, // default = true
resizable = false, // default = false
windowsTitle = "My Tray Popup", // default = "" — recommended (esp. on Linux & when undecorated=false)
windowIcon = null, // default = null — set your app icon; important on Linux & when undecorated=false
menu = { // optional (default = null)
Item("Toggle popup") { trayAppState.toggle() }
Divider()
Item("Quit") { exitApplication() }
}
) {
// Your Compose UI (DialogWindowScope receiver)
MaterialTheme {
Text("Quick Settings")
Button(onClick = { trayAppState.hide() }) { Text("Close") }
}
}
}trayAppState.show()
trayAppState.hide()
trayAppState.toggle()trayAppState.setWindowSize(400.dp, 600.dp)
// or
trayAppState.setWindowSize(DpSize(250.dp, 350.dp))// AUTO (default): closes when user clicks outside or focus is lost
val state = rememberTrayAppState(initialDismissMode = TrayWindowDismissMode.AUTO)
// MANUAL: you decide when to hide
LaunchedEffect(Unit) {
state.setDismissMode(TrayWindowDismissMode.MANUAL)
}TrayApp(
state = trayAppState,
icon = Icons.Default.Settings,
tooltip = "Quick Settings",
menu = {
val isVisible by trayAppState.isVisible.collectAsState()
Item(if (isVisible) "Hide" else "Show") { trayAppState.toggle() }
SubMenu("Size") {
Item("250×350") { trayAppState.setWindowSize(250.dp, 350.dp) }
Item("350×500") { trayAppState.setWindowSize(350.dp, 500.dp) }
Item("450×600") { trayAppState.setWindowSize(450.dp, 600.dp) }
}
}
) {
// your content
}windowsTitle and windowIcon. Even with undecorated UIs, Linux desktop environments often show a dock/taskbar entry; providing a title/icon prevents generic placeholders and improves discoverability.| App | Description |
|---|---|
| AB Download Manager | A desktop download manager |
| RIFT Intel Fusion Tool | A tool for EVE Online |
If your app uses Compose Native Tray, feel free to open a PR to add it here!
This library is licensed under the MIT License. The Linux module uses Apache 2.0
Contributions are welcome! Feel free to:
Developed and maintained by Elie Gambache with the goal of providing a modern, cross-platform solution for system tray icons in Kotlin.