🛠️ Compose Native Tray
📖 Introduction
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.
✨ Features
📑 Table of Contents
🎯 Why Compose Native Tray?
This library was created to solve several limitations of the standard Compose for Desktop solution:
- ✅ Improved HDPI support on Windows and Linux
- ✅ Modern appearance on Linux (no more Windows 95 look!)
- ✅ Extended features: checkable items, nested submenus, separators
- ✅ Native primary action: left-click on Windows/macOS, single-click (KDE) or double-click (GNOME) on Linux
- ✅ Full Compose recomposition support: fully reactive icon and menu, allowing dynamic updates of items, their states, and visibility
📸 Preview
 Windows |  macOS |
|
⚡ Installation
Add the dependency to your build.gradle.kts:
dependencies {
implementation("dev.nucleusframework:composenativetray:<version>")
implementation("dev.nucleusframework:composenativetray-app:<version>")
}
Since 2.1.0 the library is split in two so apps that only need a tray icon don't pull in the
heavier windowing backend (see
). and the menu DSL
live in and work in any Compose Desktop — no Nucleus
application scope required. lives in and needs
. Add the second artifact only when you use .
🚀 Quick Start
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 demo directory. 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
📚 Usage Guide
🎨 Creating the System Tray Icon
New: Using a DrawableResource directly
Tray(
icon = Res.drawable.myIcon,
tooltip = "My Application"
) { }
Requires compose.components.resources in your project. In this library it's already included; in your app add:
implementation(compose.components.resources)
Option 1: Using an ImageVector
Tray(
icon = Icons.Default.Favorite,
tint = null,
tooltip = "My Application"
) { }
Option 2: Using a Painter
Tray(
icon = painterResource(Res.drawable.myIcon),
tooltip = "My Application"
) { }
Option 3: Using a Custom Composable
Tray(
iconContent = {
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(
color = Color.Red,
radius = size.minDimension / 2,
center = center
)
}
},
tooltip = "My Application"
) { }
⚠️ Important: Always use Modifier.fillMaxSize() with iconContent for proper icon rendering.
Option 4: Platform-Specific Icons
This approach allows respecting the design conventions of each platform:
- Windows: Traditionally uses colored icons in the system tray
- macOS/Linux: Prefer monochrome icons that automatically adapt to the theme
val windowsIcon = painterResource(Res.drawable.myIcon)
val macLinuxIcon = Icons.Default.Favorite
Tray(
windowsIcon = windowsIcon,
macLinuxIcon = macLinuxIcon,
tooltip = "My Application"
) { }
💡 Note: If no tint is specified, ImageVectors are automatically tinted white (dark mode) or black (light mode) based on the theme.
🖱️ Primary Action
Define an action for clicking on the icon. The behavior varies by platform:
- Windows/macOS: Left-click on the icon (native implementation for macOS)
- Linux: Single-click on KDE or double-click on GNOME (implementation via DBus)
Tray(
icon = Icons.Default.Favorite,
tooltip = "My Application",
primaryAction = {
println("Icon clicked!")
}
) { }
📋 Building the 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.kt example. 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:
Icons with painterResource
New: Icons with DrawableResource in menu items
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)
Tray() {
SubMenu(
label = "Advanced",
icon = advancedIcon
) { }
}
}
🔧 Advanced Features
🔄 Fully Reactive System Menu
The library supports Compose recomposition for all aspects of the system menu:
All menu properties (icon, labels, states, item visibility) are reactive and update automatically when application states change, without requiring manual recreation of the menu.
🔑 Single Instance Management
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:
- Avoiding resource duplication and confusion with multiple windows
- Preserving the current state of the application during restoration
- Offering behavior similar to native system applications
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) {
var isWindowVisible by remember { mutableStateOf(true) }
SingleInstanceRestoreEffect {
isWindowVisible = true
}
}
See TrayAppDemo.kt for a working example (a second launch re-opens the tray popup).
Deep links
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 ->
}
}
📍 Position Detection
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()
- Windows / macOS: resolved from the native tray/menu-bar region.
- Linux: uses the desktop-environment convention (no reliable native tray-region API).
getTrayWindowPosition(...) 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.
🌓 Dark Mode Detection
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"
) { }
Platform Behavior:
- macOS: The menu bar depends on the wallpaper, not the system theme
- Windows: Follows the system theme
- Linux: GNOME/XFCE/CINNAMON/MATE always dark, KDE follows the theme
💡 macOS Note: The system tray icon follows the menu bar color (based on the wallpaper), but the menu item icons follow the system theme.
🎨 Icon Rendering Customization
Two options for customizing rendering:
Tray(
icon = Icons.Default.Favorite,
iconRenderProperties = IconRenderProperties.forCurrentOperatingSystem(
sceneWidth = 192,
sceneHeight = 192,
density = Density(2f)
)
) { }
Tray(
icon = Icons.Default.Favorite,
iconRenderProperties = IconRenderProperties.withoutScalingAndAliasing(
sceneWidth = 192,
sceneHeight = 192,
density = Density()
)
) { }
By default, icons are optimized by OS: 32x32px (Windows), 44x44px (macOS), 24x24px (Linux).
⚠️ Platform-Specific Notes
Icon Limitations
- GNOME: Icons don't display in submenus
- Windows: Checkable items with icons don't display the check indicator
Theme Behavior
- macOS: The menu bar color depends on the wallpaper, not the system theme
- Windows: Follows the system theme
- Linux: Varies by desktop environment (GNOME/KDE/etc.)
ProGuard / R8
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.** { *; }
🧪 TrayApp (Alpha)
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.
Why you’ll like it
- One‑click popup anchored to the tray/menu bar
- Auto‑dismiss on outside click (or manual if you prefer)
- State preserved: toggling visibility doesn’t remount your UI
- with
Quick Start (minimal)
Common recipes
Show / Hide / Toggle
trayAppState.show()
trayAppState.hide()
trayAppState.toggle()
Resize the popup
trayAppState.setWindowSize(400.dp, 600.dp)
trayAppState.setWindowSize(DpSize(250.dp, 350.dp))
Dismiss mode
val state = rememberTrayAppState(initialDismissMode = TrayWindowDismissMode.AUTO)
LaunchedEffect(Unit) {
state.setDismissMode(TrayWindowDismissMode.MANUAL)
}
Tray menu (compact)
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() {
Item() { trayAppState.setWindowSize(dp, dp) }
Item() { trayAppState.setWindowSize(dp, dp) }
Item() { trayAppState.setWindowSize(dp, dp) }
}
}
) {
}
Tips
- Title & icon matter: set
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.
📱 Apps Using Compose Native Tray
If your app uses Compose Native Tray, feel free to open a PR to add it here!
📄 License
This library is licensed under the MIT License. The Linux module uses Apache 2.0
🤝 Contribution
Contributions are welcome! Feel free to:
- Report bugs via issues
- Propose new features
- Submit pull requests
- Share your projects using this library
👨💻 Author
Developed and maintained by Elie Gambache with the goal of providing a modern, cross-platform solution for system tray icons in Kotlin.