🚀. Socket Launch Week Day 3:Socket Firewall Now Blocks Malicious VS Code and Open VSX Extensions.Learn more
Sign In

os-theme

Package Overview
Dependencies
Maintainers
1
Versions
6
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

os-theme

Cross-platform OS theme detection (dark/light mode) with event-driven change notifications for Node.js and Bun

latest
Source
npmnpm
Version
0.0.8
Version published
Maintainers
1
Created
Source

os-theme

npm version CI License: MIT npm downloads

Cross-platform OS theme detection (dark/light mode) with change notifications for Node.js and Bun.

Features

  • Detect current OS theme (dark or light)
  • 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)

Install

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.

Quick Start

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();

API

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();

Types

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>;
}

Use Cases

CLI app with Ink/React

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;
}

Long-running process

import { appearance } from "os-theme";

await appearance.on("change", (mode) => {
  regenerateColorPalette(mode);
});

process.on("SIGINT", async () => {
  await appearance.dispose();
  process.exit(0);
});

Terminal Theme Detection

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):

terminal.current(): Promise<ThemeMode | null>

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 null

This is a one-shot query — no polling.

terminal.on("change", listener): void

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 support

Terminalcurrent() (OSC 11)on("change") (Mode 2031)
GhosttyYesYes
Kitty (>=0.38.1)YesYes
Contour (>=0.4.0)YesYes
VTE (>=0.82)YesYes
GNOME TerminalYesVia VTE
iTerm2YesNo
Terminal.appYesNo
Windows Terminal (>=1.22)YesNo
AlacrittyYesNo
WezTermYesNo
KonsoleYesNo
footYesNo
xtermYesNo
tmuxCachedNo

When Mode 2031 is not supported, terminal.on("change") won't fire — fall back to appearance.on("change") for OS-level change detection.

Platform Details

PlatformRead mechanismListen mechanism
macOSdefaults read -g AppleInterfaceStyleNSDistributedNotificationCenter via helper subprocess (event-driven)
WindowsRegistry AppsUseLightThemeRegNotifyChangeKeyValue (event-driven)
LinuxD-Bus org.freedesktop.portal.SettingsD-Bus signal subscription (event-driven)

The native layer is written in Rust and compiled to two targets:

  • Bun: shared library (.dylib / .so / .dll) loaded via bun:ffi with threadsafe JSCallback
  • Node.js: N-API addon (.node) built with napi-rs using ThreadsafeFunction

The runtime is auto-detected — import os-theme and it picks the right backend.

macOS architecture

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 NSDistributedNotificationCenter on its own main thread
  • Prints dark\n or light\n to 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)

Architecture

┌─────────────────────────────────────┐
│  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)   │
└──────────┘               └──────────────┘

Performance

The listener is fully event-driven — zero CPU usage while idle. No polling, no timers, no busy-wait.

Resource footprint (macOS, Apple Silicon)

MetricValue
Helper binary size51 KB
Helper RSS (idle)~24 MB (macOS framework overhead)
CPU usage (idle)0.0%
Event latency~250 ms (notification → JS callback)
Extra processes1 helper subprocess
Extra file descriptors2 pipes (stdin + stdout)

Event-driven vs polling

Polling (250ms)Event-driven (current)
CPU while idlePeriodic spikes (defaults read fork every 250ms)0.0%
Worst-case latency250 ms~250 ms
Process spawns~4/second, forever1 total (helper stays alive)
Memory overheadMinimal+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.

Orphan protection

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.

Run the benchmark yourself

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.

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

Development

Prerequisites

  • Bun (runtime + test runner)
  • Rust (for compiling native library)

Setup

git clone <repo-url>
cd os-theme
bun install
bun run build:native   # compile Rust → .dylib/.so/.dll

Commands

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 dev:terminal   # terminal theme demo — toggle your terminal theme
bun run benchmark      # measure resource usage and event latency

Testing

The 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 test

Note: The integration test briefly changes your macOS appearance and restores it afterwards.

Roadmap

  • Event-driven macOS listener via NSDistributedNotificationCenter (helper subprocess)
  • Node.js compatibility via N-API (napi-rs addon, 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 --compile for single-executable distribution

License

MIT — see LICENSE

Keywords

dark-mode

FAQs

Package last updated on 06 Mar 2026

Did you know?

Socket

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.

Install

Related posts