npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

os-theme

v0.0.4

Published

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

Readme

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

| 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 Details

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

  1. Runs NSDistributedNotificationCenter on its own main thread
  2. Prints dark\n or light\n to stdout when the theme changes
  3. The Rust library reads from the pipe on a background thread and fires the JS callback
  4. 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)

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

Event-driven vs polling

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

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.

╔══════════════════════════════════════════╗
║      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

  • [x] Event-driven macOS listener via NSDistributedNotificationCenter (helper subprocess)
  • [x] Node.js compatibility via N-API (napi-rs addon, works with tsx/ts-node)
  • [x] Prebuilt binaries via npm optional dependencies (no Rust needed to install)
  • [x] CI/CD with GitHub Actions matrix builds (macOS, Windows, Linux)
  • [x] Terminal-level theme detection via Mode 2031 and OSC 11
  • [ ] bun build --compile for single-executable distribution

License

MIT — see LICENSE