
Security News
GitHub Actions Checkout Now Blocks Risky pull_request_target Checkouts
GitHub Actions checkout now blocks risky pull_request_target checkouts by default to help prevent pwn request supply chain attacks.
Cross-platform OS theme detection (dark/light mode) with event-driven change notifications for Node.js and Bun
Cross-platform OS theme detection (dark/light mode) with change notifications for Node.js and Bun.
dark or light)npm install os-theme
# or
bun add os-theme
Prebuilt 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();
appearance.current(): Promise<ThemeMode>Returns the current OS theme: "dark" or "light".
const mode = await appearance.current();
appearance.on(event, listener): Promise<void>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"
});
appearance.off(event, listener): Promise<void>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);
appearance.dispose(): Promise<void>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);
});
| 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:
.dylib / .so / .dll) loaded via bun:ffi with threadsafe JSCallback.node) built with napi-rs using ThreadsafeFunctionThe 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:
NSDistributedNotificationCenter on its own main threaddark\n or light\n to stdout when the theme changes┌─────────────────────────────────────┐
│ 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 benchmark
This 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.
╔══════════════════════════════════════════╗
║ 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/.dll
bun 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 benchmark # measure resource usage and event latency
The test suite includes both unit and integration tests:
current(), on()/off(), dispose())osascript, verifies the callback fires with the correct mode, and restores the original themebun test # all tests
bun test test/current.test.ts # just current() tests
bun test test/integration.test.ts # just the live toggle test
Note: The integration test briefly changes your macOS appearance and restores it afterwards.
NSDistributedNotificationCenter (helper subprocess)napi-rs addon, works with tsx/ts-node)bun build --compile for single-executable distributionMIT — see LICENSE
FAQs
Cross-platform OS theme detection (dark/light mode) with event-driven change notifications for Node.js and Bun
The npm package os-theme receives a total of 244,270 weekly downloads. As such, os-theme popularity was classified as popular.
We found that os-theme demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Security News
GitHub Actions checkout now blocks risky pull_request_target checkouts by default to help prevent pwn request supply chain attacks.

Product
Socket now supports Custom Roles and Repository Access Permissions so organizations can control who can access specific repositories and actions.

Product
Socket MCP now lets AI assistants review org alerts, investigate threats using the Socket threat feed, and inspect package files in addition to dependency scoring.