Dark mode detector
Detect the operating system's dark mode from Kotlin and recompose your Compose UI when the theme changes.
darkmode-detector reports whether the operating system is in dark mode and notifies you when
that changes. It provides an isSystemInDarkMode() composable that recomposes on theme changes,
plus an imperative IDarkModeDetector for use outside Compose. macOS, Windows, and Linux are
supported.
Add the dependency
dependencies {
implementation("dev.nucleusframework:nucleus.darkmode-detector:2.4.3")
}Detect dark mode in Compose
Call isSystemInDarkMode() and use the result to select your color scheme. The composable
recomposes when the system theme changes:
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import dev.nucleusframework.darkmodedetector.isSystemInDarkMode
@Composable
fun App() {
val isDark = isSystemInDarkMode()
MaterialTheme(
colorScheme = if (isDark) darkColorScheme() else lightColorScheme(),
) {
// Content recomposes when the OS theme flips.
}
}Compose Desktop's own isSystemInDarkTheme() only reads LocalSystemTheme, whose default is a
non-reactive Skiko snapshot — it does not update when the user switches themes at runtime.
isSystemInDarkMode() returns the current value and registers a listener that triggers
recomposition on every change.
Bridge to Compose isSystemInDarkTheme
From 2.3, nucleusApplication provides Compose's LocalSystemTheme from
isSystemInDarkMode() on both AWT and Tao entry points (Tao windows inherit it through the
scene local bridge). Official androidx.compose.foundation.isSystemInDarkTheme() — and any
library that calls it — then tracks live OS theme changes under the DSL, without calling
Nucleus APIs at every call site.
darkmode-detector is an api dependency of nucleus-application, so the detector is on
the consumer classpath whenever you use the application entry point. You still add the
coordinate explicitly when you use the detector outside nucleusApplication.
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import dev.nucleusframework.application.nucleusApplication
fun main() = nucleusApplication {
// isSystemInDarkTheme() recomposes when the OS theme flips
val dark = isSystemInDarkTheme()
MaterialTheme(colorScheme = if (dark) darkColorScheme() else lightColorScheme()) {
DecoratedWindow(onCloseRequest = ::exitApplication) { /* … */ }
}
}Use isSystemInDarkMode() when you need Nucleus's API outside Compose or when you are not
inside nucleusApplication. Prefer isSystemInDarkTheme() for stock Material / third-party
composables that already depend on it.
Inside a @Preview (when LocalInspectionMode is true), isSystemInDarkMode() delegates to
Compose's isSystemInDarkTheme() so previews render without a running detector.
How it works
Each platform reads the system theme through a native JNI bridge and watches the OS for changes. No polling is involved; the detector reacts to change notifications from the OS itself.
- macOS: reads the
AppleInterfaceStylesystem preference and registers an observer throughNSDistributedNotificationCenter. - Windows: reads the registry value
HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize\AppsUseLightThemeand watches it withRegNotifyChangeKeyValueon a native background thread. - Linux: reads the XDG Desktop Portal
color-schemepreference over theorg.freedesktop.portal.SettingsD-Bus interface; a value of1means prefer-dark.
On unsupported platforms, getPlatformDarkModeDetector() returns NoopDarkModeDetector, which
always reports light and ignores listeners.
API reference
Read the current theme
getPlatformDarkModeDetector() returns the IDarkModeDetector for the current platform. Call
isDark() for a one-shot read:
import dev.nucleusframework.darkmodedetector.getPlatformDarkModeDetector
val detector = getPlatformDarkModeDetector()
val dark: Boolean = detector.isDark()Listen for changes
Register a java.util.function.Consumer<Boolean> to be notified when the theme changes, and
remove it when you are done:
import dev.nucleusframework.darkmodedetector.getPlatformDarkModeDetector
import java.util.function.Consumer
val detector = getPlatformDarkModeDetector()
val listener = Consumer<Boolean> { isDark ->
println("dark mode: $isDark")
}
detector.registerListener(listener)
// Later, stop receiving updates.
detector.removeListener(listener)The full interface:
interface IDarkModeDetector {
fun isDark(): Boolean
fun registerListener(listener: Consumer<Boolean>)
fun removeListener(listener: Consumer<Boolean>)
}Use a no-op detector
NoopDarkModeDetector implements IDarkModeDetector without touching the OS. It always returns
false from isDark() and ignores listeners — useful in tests or headless environments:
import dev.nucleusframework.darkmodedetector.IDarkModeDetector
import dev.nucleusframework.darkmodedetector.NoopDarkModeDetector
val detector: IDarkModeDetector = NoopDarkModeDetectorWhat's next
- System colors — read the system accent color and high-contrast state.
- System info — query the OS, CPU, memory, and battery from Kotlin.
- OS integration — the rest of the desktop OS APIs.