Cross-platform OS theme detection (dark/light mode) with change notifications for Node.js and Bun.
- Detect current OS theme (
darkorlight) - Get notified when the theme changes
- Cross-platform — macOS, Windows, Linux
- Bun and Node support
- Zero JS dependencies
- Terminal-level theme detection via Mode 2031 and OSC 11 (no native code needed)
npm install os-theme
# or
bun add os-themePrebuilt binaries are provided for macOS (ARM64), Linux (x64), and Windows (x64). No Rust toolchain required.
import { appearance } from "os-theme";
// Read current theme
console.log(await appearance.current()); // "dark" or "light"
// Listen for changes
await appearance.on("change", (mode) => {
console.log(`Theme changed to: ${mode}`);
});
// Remove a specific listener
await appearance.off("change", myListener);
// Stop all listeners and clean up native resources
await appearance.dispose();Returns the current OS theme: "dark" or "light".
const mode = await appearance.current();Subscribe to theme changes. The listener receives the new ThemeMode whenever the OS switches between dark and light mode.
await appearance.on("change", (mode) => {
// mode is "dark" or "light"
});Remove a previously registered listener. When no listeners remain, the native watcher is automatically stopped.
const listener = (mode: ThemeMode) => console.log(mode);
await appearance.on("change", listener);
// later...
await appearance.off("change", listener);Stop all listeners and release native resources. Safe to call multiple times. After disposing, current() still works (it's a stateless read), but no more change events will fire until on() is called again.
await appearance.dispose();type ThemeMode = "dark" | "light";
interface Appearance {
current(): Promise<ThemeMode>;
on(event: "change", listener: (mode: ThemeMode) => void): Promise<void>;
off(event: "change", listener: (mode: ThemeMode) => void): Promise<void>;
dispose(): Promise<void>;
}import { appearance } from "os-theme";
import { useState, useEffect } from "react";
function useOsTheme() {
const [mode, setMode] = useState<"dark" | "light">("light");
useEffect(() => {
appearance.current().then(setMode);
appearance.on("change", setMode);
return () => { appearance.off("change", setMode); };
}, []);
return mode;
}import { appearance } from "os-theme";
await appearance.on("change", (mode) => {
regenerateColorPalette(mode);
});
process.on("SIGINT", async () => {
await appearance.dispose();
process.exit(0);
});For terminal applications, os-theme can detect the terminal's theme directly — independent of the OS setting. This is useful when a user runs a dark terminal on a light OS, or vice versa.
Two mechanisms are available, both pure JS (no native code):
Query the terminal's background color via OSC 11 and classify it as dark or light based on luminance. Returns null if not running in a TTY or the terminal doesn't respond.
import { terminal } from "os-theme";
const theme = await terminal.current(); // "dark", "light", or nullThis is a one-shot query — no polling.
Listen for terminal theme changes via Mode 2031. The terminal pushes a notification when its color scheme changes — no polling needed.
import { terminal } from "os-theme";
terminal.on("change", (mode) => {
console.log(`Terminal theme changed: ${mode}`);
});
// Clean up when done
terminal.dispose();| Terminal | current() (OSC 11) |
on("change") (Mode 2031) |
|---|---|---|
| Ghostty | Yes | Yes |
| Kitty (>=0.38.1) | Yes | Yes |
| Contour (>=0.4.0) | Yes | Yes |
| VTE (>=0.82) | Yes | Yes |
| GNOME Terminal | Yes | Via VTE |
| iTerm2 | Yes | No |
| Terminal.app | Yes | No |
| Windows Terminal (>=1.22) | Yes | No |
| Alacritty | Yes | No |
| WezTerm | Yes | No |
| Konsole | Yes | No |
| foot | Yes | No |
| xterm | Yes | No |
| tmux | Cached | No |
When Mode 2031 is not supported, terminal.on("change") won't fire — fall back to appearance.on("change") for OS-level change detection.
| Platform | Read mechanism | Listen mechanism |
|---|---|---|
| macOS | defaults read -g AppleInterfaceStyle |
NSDistributedNotificationCenter via helper subprocess (event-driven) |
| Windows | Registry AppsUseLightTheme |
RegNotifyChangeKeyValue (event-driven) |
| Linux | D-Bus org.freedesktop.portal.Settings |
D-Bus signal subscription (event-driven) |
The native layer is written in Rust and compiled to two targets:
- Bun: shared library (
.dylib/.so/.dll) loaded viabun:ffiwith threadsafeJSCallback - Node.js: N-API addon (
.node) built withnapi-rsusingThreadsafeFunction
The runtime is auto-detected — import os-theme and it picks the right backend.
macOS delivers AppleInterfaceThemeChangedNotification only on the main thread's run loop, which is owned by the Bun/Node runtime. To work around this, os-theme spawns a lightweight helper binary (os-theme-helper, ~51 KB) that:
- Runs
NSDistributedNotificationCenteron its own main thread - Prints
dark\norlight\nto stdout when the theme changes - The Rust library reads from the pipe on a background thread and fires the JS callback
- Monitors parent PPID on a background thread — exits immediately if the parent process dies (no orphans)
┌─────────────────────────────────────┐
│ Your app │
│ appearance.on("change", callback) │
│ │ │
│ ▼ │
│ TypeScript API (EventEmitter-like) │
│ │ │
│ ▼ │
│ bun:ffi (dlopen + JSCallback) │
├─────────┼───────────────────────────┤
│ ▼ Native (Rust) │
│ ┌────────────────────────────────┐ │
│ │ macOS: helper subprocess │ │
│ │ + NSDistributed │ │
│ │ NotificationCenter │ │
│ │ Windows: Registry + notify │ │
│ │ Linux: D-Bus + signal │ │
│ └────────────────────────────────┘ │
│ Event-driven on all platforms │
└─────────────────────────────────────┘
macOS detail:
┌──────────┐ stdout pipe ┌──────────────┐
│ Rust │◄──────────────│ os-theme- │
│ lib │ stdin pipe │ helper │
│ (bg │──────────────►│ (main thread │
│ thread) │ (death det.) │ run loop) │
└──────────┘ └──────────────┘
The listener is fully event-driven — zero CPU usage while idle. No polling, no timers, no busy-wait.
| Metric | Value |
|---|---|
| Helper binary size | 51 KB |
| Helper RSS (idle) | ~24 MB (macOS framework overhead) |
| CPU usage (idle) | 0.0% |
| Event latency | ~250 ms (notification → JS callback) |
| Extra processes | 1 helper subprocess |
| Extra file descriptors | 2 pipes (stdin + stdout) |
| Polling (250ms) | Event-driven (current) | |
|---|---|---|
| CPU while idle | Periodic spikes (defaults read fork every 250ms) |
0.0% |
| Worst-case latency | 250 ms | ~250 ms |
| Process spawns | ~4/second, forever | 1 total (helper stays alive) |
| Memory overhead | Minimal | +24 MB (AppKit/Foundation frameworks) |
The ~24 MB RSS is the fixed cost of loading macOS's AppKit + Foundation frameworks, required by any process using NSDistributedNotificationCenter. It does not grow over time.
The helper process monitors its parent via getppid() on a background thread (1-second interval). If the parent process exits (gracefully or via crash/SIGKILL), the helper detects PPID reparenting and exits immediately — no orphaned processes.
bun run benchmarkThis measures binary size, memory, CPU (5-second idle sample), event latency (live toggle), and orphan cleanup. It briefly changes your macOS appearance and restores it afterwards.
Example output
╔══════════════════════════════════════════╗
║ os-theme performance benchmark ║
╚══════════════════════════════════════════╝
📦 Binary sizes
Native library (dylib): 448K
Helper binary: 52K
💾 Memory usage (idle)
Bun process (RSS): 40224 KB (39.2 MB)
Helper process (RSS): 24448 KB (23.8 MB)
⏱️ CPU usage (5-second idle sample)
Bun process: 0.0%
Helper process: 0.0%
⚡ Event latency (toggle dark → light → restore)
Dark → callback: 249 ms
Light → callback: 255 ms
Average: 252 ms
🧹 Orphan protection
✅ Helper exited cleanly after parent kill
git clone <repo-url>
cd os-theme
bun install
bun run build:native # compile Rust → .dylib/.so/.dllbun run build:native # compile native library
bun test # run all tests (unit + integration)
bun run dev # interactive demo — toggle your OS theme to see events
bun run dev:terminal # terminal theme demo — toggle your terminal theme
bun run benchmark # measure resource usage and event latencyThe test suite includes both unit and integration tests:
- Unit tests — verify API contracts (
current(),on()/off(),dispose()) - Integration test — programmatically toggles macOS appearance via
osascript, verifies the callback fires with the correct mode, and restores the original theme
bun test # all tests
bun test test/current.test.ts # just current() tests
bun test test/integration.test.ts # just the live toggle testNote: The integration test briefly changes your macOS appearance and restores it afterwards.
- Event-driven macOS listener via
NSDistributedNotificationCenter(helper subprocess) - Node.js compatibility via N-API (
napi-rsaddon, works with tsx/ts-node) - Prebuilt binaries via npm optional dependencies (no Rust needed to install)
- CI/CD with GitHub Actions matrix builds (macOS, Windows, Linux)
- Terminal-level theme detection via Mode 2031 and OSC 11
-
bun build --compilefor single-executable distribution
MIT — see LICENSE