🛠️ 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("io.github.kdroidfilter:composenativetray:<version>")
}
🚀 Quick Start
Minimal example to create a system tray icon with menu:
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
- PainterResourceWorkaroundDemo.kt – demonstrates the painterResource variable workaround
- DemoWithoutContextMenu.kt – minimalist tray with primary action only
📚 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
Implementation example with SingleInstanceManager:
var isWindowVisible by remember { mutableStateOf(true) }
val isSingleInstance = SingleInstanceManager.isSingleInstance(
onRestoreRequest = {
isWindowVisible = true
}
)
if (!isSingleInstance) {
exitApplication()
return@application
}
Passing data to the main instance
In some cases, you may want to pass some data to the main instance, e.g. pass a deeplink,
that new instance got in the arguments of the main function from OS.
For this purpose you can use optional onRestoreFileCreated handler to write required data to the special file,
that will be later accessible to read in the onRestoreRequest handler of the main instance.
Both handlers have the Path as a receiver, so you can do any read/write operations on it.
SingleInstanceManager.isSingleInstance(
onRestoreFileCreated = {
args.firstOrNull()?.let(::writeText)
},
onRestoreRequest = {
log("Restored with arg: '${readText()}'")
}
)
Custom Configuration
For finer control, configure the SingleInstanceManager:
SingleInstanceManager.configuration = Configuration(
lockFilesDir = Paths.get("path/to/your/app/data/dir/single_instance_manager"),
appIdentifier = "app_id"
)
This allows limiting the scope of the single instance to a specific directory or identifying different versions of your application.
📍 Position Detection
Precisely position your windows relative to the system tray icon:
val windowWidth = 800
val windowHeight = 600
val windowPosition = getTrayWindowPosition(windowWidth, windowHeight)
Window(
state = rememberWindowState(
width = windowWidth.dp,
height = windowHeight.dp,
position = windowPosition
)
) { }
Implementation Details:
- Windows: Uses the Windows native API to get the exact position
- macOS: Uses the Cocoa API for the position in the menu bar
- Linux: Captures coordinates when clicking on the icon
The window is automatically horizontally centered on the icon and vertically positioned based on whether the system tray is at the top or bottom of the screen.
🌓 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 com.kdroid.composetray.** { *; }
🧪 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.