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

react-native-control-center

v0.4.0

Published

iOS 18 Control Center controls and Android Quick Settings tiles for React Native — declare once in TypeScript, no Swift or Kotlin required.

Readme

react-native-control-center

iOS 18 Control Center controls and Android Quick Settings tiles for React Native — declare once in TypeScript, no Swift or Kotlin required.

status iOS Android license

v0.1.0. Build-time pipeline (codegen + pbxproj wiring + Expo plugin + CLI) and runtime native module (Darwin observer → queue drain → JS events) are complete and compile end-to-end against the real iOS 18 SDK. The runtime hook gives synchronous initial state via a cache and re-renders Control Center on programmatic state change; a 5,000+ SF Symbol set backs build-time spell-check. See Installation and the Roadmap.


What it does

Declare controls in TypeScript:

// src/controls.ts
import { defineControls } from 'react-native-control-center';

export default defineControls({
  quickNote: {
    type: 'button',
    title: 'Quick Note',
    icon: 'square.and.pencil',
  },
  vpnToggle: {
    type: 'toggle',
    title: 'VPN',
    icons: { on: 'lock.fill', off: 'lock.open' },
    stateKey: 'vpnEnabled',
  },
});

Add one line to app.json:

{
  "expo": {
    "plugins": [
      ["react-native-control-center", {
        "controls": "./src/controls.ts",
        "urlScheme": "myapp"
      }]
    ]
  }
}

Run npx expo prebuild and the library:

  • generates a Widget Extension target
  • writes the ControlWidget + AppIntent Swift files
  • links AppIntents.framework and WidgetKit.framework
  • wires up App Group entitlement for two-way state sync
  • registers a URL scheme for deep linking

React to taps in your app:

import { ControlCenter, useControlState } from 'react-native-control-center';

// Button taps
ControlCenter.onAction(({ id }) => {
  if (id === 'quickNote') navigation.navigate('NewNote');
});

// Bidirectional toggle state
const [isVPN, setVPN] = useControlState<boolean>('vpnEnabled');

Requirements

| | Minimum | | --- | --- | | iOS (controls visible) | 18.0+ — on iOS 17 and below the library loads and no-ops; controls just don't appear | | Xcode | 16+ (ships the iOS 18 SDK with ControlWidget / WidgetKit ControlCenter) | | React Native | 0.74+ | | Expo (optional) | SDK 54+ if you use the config plugin | | Android (tiles) | 7.0+ (API 24) — Quick Settings tiles; below that the library no-ops |

The same defineControls config drives both platforms.

Installation

npm install react-native-control-center

Expo — add the config plugin to app.json, then prebuild:

{ "expo": { "plugins": [["react-native-control-center", {
  "controls": "./src/controls.ts",
  "urlScheme": "myapp"
}]] } }
npx expo prebuild --clean && npx expo run:ios

Bare React Native — add a rnControlCenter block to package.json, then run the CLI:

{ "rnControlCenter": { "controls": "./src/controls.ts", "urlScheme": "myapp" } }
npx rn-control-center generate && cd ios && pod install && cd .. && npx react-native run-ios

See runnable references in examples/expo and examples/bare-rn.

Why this exists

Apple's iOS 18 Control Widgets are powerful, but wiring them up from React Native currently means:

  1. Opening Xcode
  2. Adding a Widget Extension target
  3. Writing SwiftUI ControlWidget by hand
  4. Defining an AppIntent
  5. Configuring an App Group
  6. Setting up a URL scheme
  7. Bridging taps back to JS

@bacons/apple-targets solves step 1 — but leaves you with Swift, plists, and entitlements to manage yourself.

This library takes a declarative TypeScript config and generates the full native extension, including the pieces needed for two-way state sync with your React Native app.


How it works

There are two distinct flows worth understanding: what happens at build time when you run expo prebuild (or rn-control-center generate), and what happens at runtime when a user taps a control in Control Center.

Build-time pipeline

[ npx expo prebuild ]                      [ npx rn-control-center generate ]
        │                                              │
        ▼                                              ▼
 Expo reads app.json plugins              cli/bin reads package.json
        │                                              │
        ▼                                              ▼
 plugin/index.ts                          cli/runGenerate.ts
   withControlCenter(config, props)         runGenerate({ projectRoot })
        │                                              │
        ├── validateProps()                            │
        │                                              │
        ├── withDangerousMod(...) ────────┐            │
        │       parseControlsFile()       │            │
        │       generateNativeFiles()     │            │
        │       fs.writeFileSync(...)     │            │
        │                                 │            │
        └── withXcodeProject(...) ────────┘            │
                wireXcodeProject(project, opts)        │
                                                       │
                                                       ▼
        ┌──── parseControlsFile()  ◄────  reads ./src/controls.ts and turns
        │                                  the defineControls({...}) literal
        │                                  into ParsedControl[] (Babel AST)
        │
        ├──── generateNativeFiles()  ──►  emits 8 NativeFile records:
        │                                  • ControlBundle.swift
        │                                  • ControlStore.swift
        │                                  • Controls/<Name>Control.swift × N
        │                                  • Intents/<Name>Intent.swift × N
        │                                  • Info.plist
        │                                  • <ext>.entitlements (widget)
        │                                  • MainApp.entitlements (main app)
        │
        ├──── fs.writeFileSync(...)  ──►  writes the eight files into
        │                                  ios/ControlCenterExtension/
        │
        └──── wireXcodeProject(...)  ──►  mutates project.pbxproj:
                  ├── addWidgetExtensionTarget()       app-extension target
                  │                                    + auto PBXCopyFilesBuildPhase
                  │                                      embedding the .appex
                  ├── linkFrameworks(widget,           one PBXFileReference,
                  │     ['WidgetKit','SwiftUI',         one PBXBuildFile per
                  │      'AppIntents'])                 target's Frameworks phase
                  ├── linkFrameworks(mainApp,
                  │     ['AppIntents'])
                  ├── addSyncedSourceFolder()          PBXFileSystemSynchronizedRootGroup
                  │                                    + 2 ExceptionSets:
                  │                                      • shared files → main app
                  │                                      • plist/entitlements → exclude widget
                  ├── setTargetBuildSettings(widget,   IPHONEOS_DEPLOYMENT_TARGET=18.0,
                  │     {...})                          INFOPLIST_FILE,
                  │                                     CODE_SIGN_ENTITLEMENTS,
                  │                                     GENERATE_INFOPLIST_FILE=NO, ...
                  ├── setTargetBuildSettings(mainApp,  CODE_SIGN_ENTITLEMENTS,
                  │     {...})                          IPHONEOS_DEPLOYMENT_TARGET≥16.0
                  └── verifyEmbedded()                  sanity check
                                                       │
                                                       ▼
                                                 project.writeSync()
                                                       │
                                                       ▼
                                                 CocoaPods install
                                                       │
                                                       ▼
                                                 ios/ ready to xcodebuild

Runtime — Button tap (e.g. "Quick Note")

①  user taps "Quick Note" in Control Center
        │
        ▼
②  iOS wakes the widget extension process
        │
        ▼
③  QuickNoteIntent.perform() runs (in widget process)
        │   ControlStore.shared.enqueueAction(id, deepLink)
        │     └── push event to App Group UserDefaults queue
        │     └── post a Darwin notification
        │   return .result()
        │
        ▼
④  iOS sees `static let openAppWhenRun: Bool = true`
        └── brings the main app to the foreground
        │
        ▼
⑤  Main app starts/resumes
        └── (Week 5) Native Module observes the Darwin notification,
            drains the App Group queue, emits a JS event
        │
        ▼
⑥  ControlCenter.onAction(({ id }) => ...) fires in JS

Runtime — Toggle tap (e.g. "VPN")

Two phases interleave: rendering (whenever Control Center asks the widget to draw itself) and action (when the user actually taps the toggle).

[ rendering ]
①  Control Center asks the widget for its current state
        │
        ▼
②  Provider.currentValue() runs
        └── ControlStore.shared.getBool('vpnEnabled')
              └── reads from App Group UserDefaults
        │
        ▼
③  iOS draws the toggle with the returned value
        └── on-icon vs off-icon, on-tint vs off-tint

[ action ]
①  user taps the toggle (currently OFF)
        │
        ▼
②  iOS computes the new value (true) and injects into VpnToggleIntent.value
        │
        ▼
③  VpnToggleIntent.perform() runs
        │   ControlStore.shared.setBool('vpnEnabled', true)
        │     └── write to App Group UserDefaults FIRST
        │   ControlStore.shared.enqueueStateChange('vpnEnabled', true)
        │     └── push event to queue + post Darwin notification
        │   return .result()
        │
        ▼
④  iOS re-runs the rendering flow above; toggle visually flips to ON
        │
        ▼
⑤  (Week 5) Native Module drains the queue, emits a JS event
        └── useControlState('vpnEnabled') hook updates → UI rerenders

API

defineControls(map)

Build-time only. Declares your controls as a literal object (literal values only — no variables or function calls, so codegen never runs your code).

defineControls({
  quickNote: { type: 'button', title: 'Quick Note', icon: 'square.and.pencil', deepLink?, tint?, description? },
  vpnToggle: { type: 'toggle', title: 'VPN', icons: { on, off }, stateKey: 'vpnEnabled', tint?, description? },
});

Dynamic (user-configurable) buttons

Add a parameter to a button and it becomes configurable: when the user adds the control, iOS shows a picker of your declared options, and the chosen value arrives in onAction as params. One declaration covers many variants — the user can even add the same control multiple times with different values.

defineControls({
  openPlace: {
    type: 'button',
    title: 'Open Place',
    icon: 'mappin',
    parameter: {
      key: 'place',                 // arrives as params.place
      title: 'Place',               // label shown in the iOS config screen
      options: [
        { value: 'home', label: 'Home' },
        { value: 'work', label: 'Work' },
        { value: 'gym',  label: 'Gym'  },
      ],
    },
  },
});
ControlCenter.onAction(({ id, params }) => {
  if (id === 'openPlace') navigate(params?.place); // 'home' | 'work' | 'gym'
});

Static vs dynamic: without parameter a button always does one thing; with it, the value is chosen by the user at add-time (a fixed list you declare — runtime/queried options are planned, not in 0.2.0).

ControlCenter

Runtime singleton. No-ops safely on unsupported platforms (off iOS 18 / Android).

| Member | Description | | --- | --- | | isAvailable(): boolean | true when the native module is loaded (iOS 18+ or Android) | | onAction(cb): () => void | Fires when a button/button-tile is tapped; cb({ id, deepLink?, params?, t }). params holds the chosen value for dynamic buttons. Returns an unsubscribe fn | | onStateChange<T>(key, cb): () => void | Fires when a toggle's stateKey changes. Returns an unsubscribe fn | | getState<T>(key): Promise<T \| null> | Read shared state (App Group on iOS / SharedPreferences on Android) | | setState<T>(key, value): Promise<void> | Write shared state; on iOS reloads Control Center so the toggle re-renders | | requestAddTile(id, label?): Promise<void> | Android 13+: show the system "add this tile?" prompt. No-op on iOS / older Android |

useControlState<T>(stateKey)

React hook over a toggle's state — const [value, setValue] = useControlState<boolean>('vpnEnabled'). First render is synchronous from a cache (no null flicker on cold start), then stays in sync with both in-app setValue calls and Control Center taps.

Android (Quick Settings tiles)

The same defineControls config also generates Android Quick Settings tiles — no separate config:

| Control | Android tile | | --- | --- | | button | a tile that, on tap, fires onAction and opens the app via the deep link | | toggle | a tile with on/off state, kept in sync through useControlState / onStateChange | | button + parameter | the parameter is iOS-only; on Android it renders as a plain button tile |

What the library does for you: generates a TileService per control, registers it in AndroidManifest.xml, ships the shared store + native module (autolinked), and registers your urlScheme so tiles can open the app. Works from both the Expo config plugin and the bare-RN CLI (npx rn-control-center generate).

Tile icon. Add androidIcon: 'ic_name' to a control to use @drawable/ic_name for its tile; without it the tile falls back to the app launcher icon. Unlike iOS SF Symbols there's no bundled glyph — ship the drawable in your Android res/.

Adding tiles to the panel. Android can't force a tile into Quick Settings — the user adds it. Call ControlCenter.requestAddTile(id, label?) to show the system "add this tile?" prompt (Android 13+; no-ops elsewhere), or the user adds it from the QS edit screen. Unlike iOS, tiles run in the app process, so no App Group is needed.

Troubleshooting

  • Control doesn't appear in Control Center — it's iOS 18+ only; add the control via Control Center's edit screen (+). Confirm expo prebuild / rn-control-center generate ran and the widget target is in your Xcode project.
  • Toggle doesn't sync with the app — both targets must share the App Group. The plugin/CLI generate the entitlement and inject RNControlCenterAppGroup into the app Info.plist; if you changed the bundle id, re-run generation.
  • cannot find 'ControlStore' or App Group errors when building — re-run generation after changing controls, then pod install.
  • An icon renders blank — the build prints a warning for unknown SF Symbol names; check the spelling in SF Symbols.app.

Status

Week 8 (May 2026) — v0.1.0 release prep — docs, license, examples, slimmed package ✅   ·   138 tests passing

What Week 8 added:

  • [x] Docs — Requirements, Installation, full API reference, and Troubleshooting sections (above)
  • [x] examples/bare-rn — RN CLI example mirroring the Expo one (rnControlCenter config + rn-control-center generate)
  • [x] MIT LICENSE file (the license field had no accompanying file)
  • [x] Slimmer tarballfiles no longer ships the source .ts that the built lib/ already provides (≈108 kB → 71 kB packed); native sources, templates, CLI bin, and types are all still included
  • [x] v0.1.0npm run build + npm pack verified; prepublishOnly runs typecheck + tests + build

Publishing to npm (npm publish) is the one remaining manual, irreversible step — run it when you're logged in (npm whoami).


Earlier status

Week 7 (May 2026) — Expo example + xcodebuild compile E2E ✅ — a real host-app compile caught three bugs the JS tests couldn't: a Swift module boundary (native module in the Pod couldn't see the app-generated ControlStore → fixed with a Pod-side ControlStoreRuntime reading config from Info.plist), a non-existent hasListeners member, and a too-high podspec deployment target. Also fixed package.json main/types.

Week 6 (May 2026) — runtime hook polished + full SF Symbol set with build-time validation ✅   ·   136 tests passing

What works today:

  • [x] defineControls({...}) types + ~200 curated SF Symbols literal union (autocomplete)
  • [x] Full SF Symbol set (5,359 names, generated from symbolist, MIT) — build-time spell-check warns on typos like lock.filll without blocking the build
  • [x] useControlState polish — synchronous initial value from a JS cache seeded by a native initialState constant (no first-render null flicker on cold start); programmatic setState calls ControlCenter.shared.reloadAllControls() so the Control Center toggle re-renders immediately
  • [x] Babel AST parser with literal-only policy and line-aware errors
  • [x] Handlebars templates for Button + Toggle controls, intents, and ControlStore.swift
  • [x] generateNativeFiles() — emits 8 Swift/plist/entitlement files tagged with target membership
  • [x] wireXcodeProject() — mutates project.pbxproj to add the widget target, link frameworks, register the synced folder + ExceptionSets, and apply build settings on both targets
  • [x] Expo Config Plugin (plugin/index.ts) wires the entire pipeline into expo prebuild
  • [x] Standalone CLI (npx rn-control-center generate) runs the same pipeline for bare RN CLI projects
  • [x] End-to-end build validated: in a real Expo app, expo prebuild produces a project that builds with xcodebuild, the control shows up in iOS Control Center, and tapping it opens the main app — the failure mode that bacons-based setups hit because they couldn't put the AppIntent in both targets is solved here by the ExceptionSet flow
  • [x] Native Module (ios/RNControlCenter.swift + .mm) — Darwin notification observer with Unmanaged pointer trick, cold-start queue drain, App Group UserDefaults get/set exposed to JS via Promise. Legacy Bridge (RCT_EXTERN_MODULE); TurboModule migration planned for v0.2
  • [x] .podspec — CocoaPods integration; library autolinks into a consumer RN app's pod install
  • [x] JS wrapper (src/ControlCenter.ts) — NativeEventEmitter over the native module; onAction / onStateChange event subscriptions, getState / setState Promise-based; safe no-op on Android and pre-iOS-18

Roadmap

| Week | Milestone | Status | | ---- | --------- | ------ | | 1 | Scaffold + AST parser + Button Swift templates | ✅ | | 2 | Toggle template + ControlStore runtime + Info.plist / entitlement generation | ✅ | | 3 | pbxproj target wiring (target add, framework link, membership, build settings) | ✅ | | 4 | Expo Config Plugin + standalone CLI (rn-control-center generate) | ✅ | | 5 | Native Module (Darwin notifications + App Group UserDefaults) | ✅ | | 6 | Full SF Symbol set + validation + useControlState runtime (cache + reload) | ✅ | | 7 | Expo example app + xcodebuild compile E2E (host app + extension link on real SDK) | ✅ | | 8 | Documentation + RN CLI example + v0.1.0 release prep (publish = manual step) | ✅ |

v0.4.0: Android polish — bare-RN CLI now emits tiles too (parity with the Expo plugin), ControlCenter.requestAddTile() shows the system add-tile prompt, and androidIcon sets a per-tile drawable. ✅

v0.3.0: Android Quick Settings tiles — the same defineControls config now generates TileServices (button + toggle), autolinks the Kotlin runtime, and registers the deep link scheme. Verified on an emulator (tile tap → ControlStore). ✅

v0.2.0: dynamic intents — user-configurable button controls (a parameter with selectable options; the choice arrives in onAction.params). ✅

Later: Lock Screen / Action Button control targets, SF Symbol → Material auto icon mapping, runtime/queried dynamic options.


Development

git clone https://github.com/alstjd8826/react-native-control-center.git
cd react-native-control-center
npm install --legacy-peer-deps

npm run typecheck   # tsc --noEmit
npm test            # jest, 138 tests

The repo is structured as a publishable RN library plus the tooling that backs it:

src/      → public API shipped to consumers
core/     → parser + codegen (shared by Expo plugin and CLI)
plugin/   → Expo Config Plugin entry point
cli/      → standalone `rn-control-center` binary
ios/      → native module sources
scripts/  → tooling (e.g. gen-sf-symbols.mjs regenerates the symbol list)

Design notes

  • Literal-only configs. defineControls({...}) must contain literal values only; variable references and function calls are rejected at parse time. This lets codegen run without ever executing user code.
  • Independent of @bacons/apple-targets. Bacons is a great general-purpose target plugin, but was tripped up by @expo/prebuild-config path changes in Expo SDK 54 during early prototyping. This library talks to pbxproj directly through the xcode npm package for a narrower, more stable surface.
  • App Group–backed state. Toggle state lives in a suite UserDefaults shared between the main app and the widget extension; the library generates the entitlement and provisions a sensible default group ID.

License

MIT