Nucleus
OS integration

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

build.gradle.kts
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 AppleInterfaceStyle system preference and registers an observer through NSDistributedNotificationCenter.
  • Windows: reads the registry value HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize\AppsUseLightTheme and watches it with RegNotifyChangeKeyValue on a native background thread.
  • Linux: reads the XDG Desktop Portal color-scheme preference over the org.freedesktop.portal.Settings D-Bus interface; a value of 1 means 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 = NoopDarkModeDetector

What'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.