react-native-nitro-compass 1.2.0
Install from the command line:
Learn more about npm packages
$ npm install @omarsdev/react-native-nitro-compass@1.2.0
Install via package.json:
"@omarsdev/react-native-nitro-compass": "1.2.0"
About this version
Fast, accurate compass heading for React Native, powered by Nitro Modules.
-
Android: raw
TYPE_MAGNETIC_FIELD_UNCALIBRATED+TYPE_ACCELEROMETERfed throughSensorManager.getRotationMatrix()+getOrientation(), with aTYPE_GAME_ROTATION_VECTORcomplementary filter on top for steady-state smoothness. This path is stateless — when a magnet or laptop is removed, the very next sample produces the correct heading instead of waiting for OS-level fusion to re-converge. We also detect OS hard-iron-bias jumps as a separate interference signal, so weak magnet events that don't push field magnitude out-of-band still register. Sensor delivery on a dedicatedHandlerThread— never blocks the UI thread. -
iOS:
CLLocationManagerheading viaCLHeading.magneticHeadingfor direction;CMDeviceMotion.magneticField(calibrated) for field strength + interference detection. Apple's stack handles sensor fusion natively. -
JS API: type-safe Nitro callbacks — no
NativeEventEmitter, no string event names. OptionaluseCompass()React hook bundles subscription lifecycle, calibration / interference observation, and live-tuneable knobs into one ergonomic call.
Most React Native compass libraries use Android's TYPE_ROTATION_VECTOR, which feels great until you put a magnet, a phone, or a laptop next to the device — then the OS-level Kalman filter holds a poisoned bias estimate for many seconds after the source is removed. This library computes heading directly from raw accelerometer + magnetometer via getRotationMatrix() (the same approach used by popular consumer compass apps), so recovery from interference is instant. We trade a few degrees of steady-state jitter for stateless behaviour, then add back smoothness via two layers: an adaptive input-side low-pass on the accel and mag vectors, plus a TYPE_GAME_ROTATION_VECTOR (gyro+accel) complementary filter that integrates Δyaw between events and lets mag samples pull it back to absolute. The end result tracks fast turns without lag, ignores transient magnet events, and snaps back instantly when interference clears.
- React Native 0.76.0 or higher
- Node 18.0.0 or higher
-
react-native-nitro-modulespeer dependency
npm install react-native-nitro-compass react-native-nitro-modulesiOS:
cd ios && pod installimport { NitroCompass } from 'react-native-nitro-compass'
if (NitroCompass.hasCompass()) {
NitroCompass.start(1, ({ heading, accuracy }) => {
console.log(`heading: ${heading.toFixed(1)}°, accuracy: ±${accuracy}°`)
})
}
// later…
NitroCompass.stop()NitroCompass.start(filterDegrees: number, onHeading: (sample: CompassSample) => void): void
NitroCompass.stop(): void
NitroCompass.isStarted(): boolean
NitroCompass.hasCompass(): boolean
NitroCompass.setFilter(degrees: number): void
NitroCompass.setSmoothing(alpha: number): void
NitroCompass.setDeclination(degrees: number): void
NitroCompass.setLocation(latitude: number, longitude: number): void
NitroCompass.setPauseOnBackground(enabled: boolean): void
NitroCompass.getCurrentHeading(): CompassSample | undefined
NitroCompass.getDiagnostics(): SensorDiagnostics | undefined
NitroCompass.getDebugInfo(): DebugInfo
NitroCompass.setOnCalibrationNeeded(onChange: (quality: AccuracyQuality) => void): void
NitroCompass.setOnInterferenceDetected(onChange: (interferenceDetected: boolean) => void): void
NitroCompass.recalibrate(): void
NitroCompass.getPermissionStatus(): PermissionStatus
NitroCompass.requestPermission(): Promise<PermissionStatus>
interface CompassSample {
heading: number // degrees, [0, 360); magnetic by default, true-north if setDeclination was called
accuracy: number // degrees, smaller is better; -1 if unknown
fieldStrengthMicroTesla: number // µT magnitude of the local magnetic field; -1 until first reading
}
type AccuracyQuality = 'high' | 'medium' | 'low' | 'unreliable'
type PermissionStatus = 'granted' | 'denied' | 'unknown'
type SensorKind =
| 'magnetometer'
| 'coreLocation'
| 'rotationVector' // legacy, no longer returned
| 'geomagneticRotationVector' // legacy, no longer returned
interface SensorDiagnostics { sensor: SensorKind }
interface DebugInfo {
interferenceActive: boolean
msSinceLastBiasJump: number // -1 if never seen / iOS
expectedFieldMicroTesla: number // -1 if setLocation not called
lastFieldMicroTesla: number // -1 if no reading
fusedYawDeg: number // NaN before first sample / iOS
lastYawRateDegPerS: number // 0 if game-RV unavailable
hasGameRotationVector: boolean // false on iOS
usingUncalibratedMag: boolean // false on iOS
}-
filterDegrees— minimum change between successive samples before the next one is delivered. Pass0for "every event"; typical UI values are1–3. UsesetFilter()to change live without tearing down the subscription. -
setSmoothing(alpha)— low-pass smoothing factor (EMA α) applied to heading samples on Android. Range(0, 1], default0.2(~100 ms time constant at 50 Hz).1.0disables smoothing; smaller values smooth more (kills jitter, adds a touch of latency). No-op on iOS —CLLocationManagerfilters internally with Apple's own algorithm, so layering an EMA on top would only add latency. See Smoothing below. -
start()is idempotent in the destructive sense — calling it while already started silently replaces the previous subscription with the new callback.stop()is idempotent and safe from inside theonHeadingcallback. -
getDiagnostics()reports which sensor would produce headings on this device. On Android this is alwaysmagnetometerfor current builds (older versions returnedrotationVector/geomagneticRotationVector); on iOS it'scoreLocation. Safe to call beforestart(). -
accuracyis a numeric uncertainty (degrees). On iOS it comes fromCLHeading.headingAccuracydirectly. On Android it's a coarse degree estimate derived from the magnetometer'sSensorManager.SENSOR_STATUS_*accuracy bucket — Android's figure-8 calibration signal — mapped toHIGH→5°,MEDIUM→15°,LOW→30°. -
fieldStrengthMicroTeslais the magnitude of the local magnetic field in µT, or-1until the first reading lands. Earth's field is normally 25–65 µT — values well outside this band signal external interference (laptops, monitors, magnets, ferrous metal). Useful for rendering a field-strength meter à la consumer compass apps. -
getCurrentHeading()returns the most recently emitted sample (with declination already applied), orundefinedif not started yet or no sample has arrived. -
setLocation(lat, lon)lets the library tighten the interference detection band on Android. With a valid location, the generic 20–70 µT "Earth field" band is replaced byexpectedField ± 15 µT, whereexpectedFieldcomes from the WMM2025 model bundled inGeomagneticField. This catches weak interference at high/low latitudes where Earth's natural field is near or above 60 µT. PassNaNor out-of-range values to revert to the generic band. No-op on iOS —CLLocationManageralready uses GPS-derived location internally. -
recalibrate()is a manual nudge for stuck calibration state. On Android it re-registers the sensor listeners (often nudges the magnetometer driver to re-evaluate soft/hard-iron calibration); on iOS it dismisses the system heading-calibration overlay and stops/restarts heading updates. Idempotent; safe to call beforestart(). The user still has to move the device through varying orientations — this just clears cached state so progress is reflected promptly. Useful behind a "Refresh" button in your calibration UI. -
getDebugInfo()returns a live snapshot of internal pipeline state. Intended for diagnosing user-reported issues — not needed for normal operation. The bundled<DebugPanel />in example/components/DebugPanel.tsx polls it at 4 Hz behind a collapsible footer; copy/adapt it into your debug build to make user bug reports self-diagnosing. Most fields are Android-only — see the inline JSDoc on theDebugInfointerface. -
getPermissionStatus()/requestPermission()map to platform location permission. Always'granted'on Android (sensors require no permission). On iOS,getPermissionStatus()readsCLLocationManager.authorizationStatus;requestPermission()prompts the system "Allow location" dialog if status is'unknown'and resolves once the user makes a choice. iOS does not re-prompt — subsequent calls resolve immediately with the cached status.
setOnCalibrationNeeded(cb) registers a callback fired whenever the calibration bucket transitions. Each platform's bucket is derived from its native accuracy semantics, since the underlying values are not directly comparable:
-
iOS uses
CLHeading.headingAccuracy(degrees). Apple is conservative — even well-calibrated iPhones typically report10–15°and rarely below5°(per Apple staff on the developer forums). Buckets:<20°→'high',<35°→'medium',<55°→'low', otherwise'unreliable'. The system's "wave the device in a figure-8" prompt is suppressed and reported to your callback as'unreliable'— show your own UI when you receive that bucket. -
Android uses the magnetometer's
SensorManager.SENSOR_STATUS_*bucket fromonAccuracyChangeddirectly (HIGH/MEDIUM/LOW/UNRELIABLE) — Android's signal that the user should do (or has done) a figure-8 to recalibrate. When magnetic interference is currently detected, the surfaced bucket is downgraded by one notch (HIGH→MEDIUM,MEDIUM→LOW,LOW→UNRELIABLE) — calibration ("the magnetometer needs to be tuned") and interference ("the field is currently being skewed by something nearby") are independent signals, and surfacingquality='high'alongsideinterfering=trueis contradictory UX.
Both platforms can plausibly emit 'high' on a clean device — the threshold split just reflects each OS's reporting style.
NitroCompass.setOnCalibrationNeeded((q) => {
if (q === 'unreliable') showCalibrationToast()
})setOnInterferenceDetected(cb) fires true when the raw magnetic field magnitude leaves the normal Earth band (~20–70 µT) and false when it returns. Typical sources are laptops, monitors, car engines, and large steel structures — these can skew heading by tens of degrees.
Interference is surfaced three ways: (1) directly via this callback, (2) on Android, the calibration bucket emitted by setOnCalibrationNeeded is downgraded by one notch while interference is detected (see the Calibration section above), and (3) every CompassSample carries fieldStrengthMicroTesla so you can render a live strength meter. On iOS, the calibration downgrade is skipped — CLLocationManager's own accuracy reporting already responds to magnetometer disturbance, so a separate downgrade would double-count.
NitroCompass.setOnInterferenceDetected((interfering) => {
if (interfering) showInterferenceWarning()
else hideInterferenceWarning()
})Detection on Android combines two signals: (1) the raw magnetic field magnitude leaving the Earth band, and (2) recent OS hard-iron-bias jumps on TYPE_MAGNETIC_FIELD_UNCALIBRATED. The bias-jump signal catches weak interference events the magnitude check alone would miss — e.g. another phone placed on top of yours, where the corrected field magnitude stays near 50 µT but the OS still revises its bias estimate. Either signal flips interfering to true; both must clear (and a 1.5 s grace window expire) before false is reported. If you call setLocation(lat, lon), the magnitude band tightens to expectedField ± 15 µT for a more sensitive gate at high or low latitudes.
On iOS, detection uses CMDeviceMotion.magneticField (calibrated, with the device's own hard-iron bias subtracted in real time). Transitions wait for CoreMotion's bias estimate to converge (5 consecutive non-uncalibrated samples — typically a second or two of normal device movement after subscribe) so the first second post-start() doesn't fire false positives.
Only triggered while start() is active; no debounce, so brief excursions still fire.
setLocation(lat, lon) tightens the Android interference gate from the generic 20–70 µT band to expectedField ± 15 µT, where expectedField comes from the WMM2025 model bundled in GeomagneticField. This catches weak interference at high or low latitudes where Earth's natural field is near or above 60 µT — exactly the cases where the generic band is too loose to detect, say, another phone placed nearby.
Pair it with any geolocation library — the example below uses react-native-geolocation-service:
import { useEffect } from 'react'
import { Platform, PermissionsAndroid } from 'react-native'
import Geolocation from 'react-native-geolocation-service'
import { useCompass } from 'react-native-nitro-compass'
async function ensureLocationPermission(): Promise<boolean> {
if (Platform.OS === 'android') {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.ACCESS_COARSE_LOCATION,
)
return granted === PermissionsAndroid.RESULTS.GRANTED
}
// iOS — useCompass already prompts for location auth for the compass
// itself, so a granted permission lets us read position too.
return true
}
function CompassScreen() {
const compass = useCompass({ enabled: true })
const { setLocation } = compass
useEffect(() => {
let cancelled = false
let watchId: number | undefined
void (async () => {
if (!(await ensureLocationPermission()) || cancelled) return
// One-shot fix at start. Coarse accuracy is fine — Earth's
// field varies < 0.5 % per km, so a city-block-resolution
// position is more than enough for the ±15 µT tolerance.
Geolocation.getCurrentPosition(
({ coords }) => {
if (!cancelled) setLocation(coords.latitude, coords.longitude)
},
() => {
/* swallow — falls back to the generic 20–70 µT band */
},
{ enableHighAccuracy: false, timeout: 15_000, maximumAge: 60_000 },
)
// Optional: keep `expectedField` in sync if the user moves
// long-distance. A 50 km move shifts the WMM-derived field by
// ~0.3 µT — well inside the tolerance — so a coarse 10 km /
// 10 minute filter is plenty.
watchId = Geolocation.watchPosition(
({ coords }) => {
if (!cancelled) setLocation(coords.latitude, coords.longitude)
},
() => {},
{
enableHighAccuracy: false,
distanceFilter: 10_000,
interval: 10 * 60 * 1000,
},
)
})()
return () => {
cancelled = true
if (watchId !== undefined) Geolocation.clearWatch(watchId)
}
}, [setLocation])
// …render compass.reading, compass.quality, etc.
}A few notes:
-
setLocationfromuseCompass()has a stable identity, so listing it in the effect dependency array is safe — it won't re-run on every compass tick. -
No location permission required for the compass itself on Android (sensors are unrestricted). The location permission requested above is purely so
react-native-geolocation-servicecan give you a fix; if it's denied, the compass still works — it just falls back to the generic interference band. -
iOS is a no-op for
setLocation—CLLocationManageralready uses GPS-derived location internally for all field-related reasoning, so calling it changes nothing on iOS. The recipe still works cross-platform; it's just that on iOS the call is effectively wasted. You can guard withif (Platform.OS === 'android')if you'd rather skip the geolocation request entirely on iOS. -
One-shot vs watch: if your app is stationary (typical phone use), the one-shot
getCurrentPositionis enough. ThewatchPositiononly matters if your user is driving / flying long distances; the field strength changes slowly enough that a coarse low-frequency watch is fine. -
Pass
NaNto revert to the generic band if the location becomes stale or the user revokes permission:setLocation(NaN, NaN).
Headings are magnetic by default. You can either apply declination in JS, or let the native side do it once via setDeclination(deg) so every emitted sample (and getCurrentHeading()) is true-north.
import geomagnetism from 'geomagnetism'
const declination = geomagnetism.model().point([lat, lon]).decl
// Option A — JS-side
const trueHeading = (heading + declination + 360) % 360
// Option B — native-side (subsequent samples are true-north)
NitroCompass.setDeclination(declination)Pass 0 to revert to magnetic. Declination survives stop()/start() cycles.
Android's raw accelerometer + magnetometer heading jitters by ±1–3° even at rest. iOS's CLLocationManager filters internally; Android does not. The library applies a circular EMA low-pass filter on (sin θ, cos θ) (handles 359°→0° wraparound cleanly) before delivering samples, with α = 0.2 by default — the same value used in phishman3579/android-compass and within the range used by Trail Sense's production compass code.
Tune live:
NitroCompass.setSmoothing(0.2) // default — kills jitter, ~100 ms latency
NitroCompass.setSmoothing(0.4) // snappier, more visible jitter
NitroCompass.setSmoothing(1.0) // disabled — every sample passes throughsetSmoothing is a no-op on iOS — Apple's stack already filters heading internally, so layering an EMA on top would only add latency without removing noise.
By default the underlying sensor / location-manager subscription is silently paused while the app is backgrounded and resumed when it returns to the foreground; the JS callback and any declination set via setDeclination are preserved across the pause. To opt out (e.g. for a fitness tracker that needs heading while screen-off):
NitroCompass.setPauseOnBackground(false)For React consumers, the bundled hook wraps the entire surface — subscription lifecycle, calibration/interference callbacks, and the live-tuneable knobs — into one ergonomic call. Multiple useCompass() mounts safely share the same underlying native subscription via JS-side fan-out, so two screens can both consume heading without clobbering each other.
import { useCompass } from 'react-native-nitro-compass'
function CompassView() {
const { reading, quality, interfering, hasCompass } = useCompass({
filterDegrees: 1,
smoothingAlpha: 0.2,
declination: 0,
pauseOnBackground: true,
enabled: true,
})
if (!hasCompass) return <Text>No compass on this device.</Text>
if (!reading) return <Text>Acquiring heading…</Text>
return (
<View>
<Text>{reading.heading.toFixed(0)}° (±{reading.accuracy.toFixed(0)}°)</Text>
{quality === 'unreliable' && <Text>Calibration needed</Text>}
{interfering && <Text>Magnetic interference</Text>}
</View>
)
}function useCompass(options?: UseCompassOptions): UseCompassResult| Option | Type | Default | Description |
|---|---|---|---|
filterDegrees |
number |
1 |
Minimum change between successive samples in degrees. Pass 0 for "every event". Updated live via NitroCompass.setFilter() whenever the prop changes. |
smoothingAlpha |
number |
0.2 |
Low-pass smoothing factor (EMA α) on Android. 1.0 disables smoothing; smaller values smooth more. No-op on iOS. See Smoothing. |
declination |
number |
0 |
Magnetic-to-true offset in signed degrees. Pull from a model like geomagnetism keyed on the user's lat/lon. When non-zero, every emitted sample is true-north. |
pauseOnBackground |
boolean |
true |
Pause the underlying sensor / location-manager subscription while the app is backgrounded and resume on foreground. |
enabled |
boolean |
true |
Toggle the heading subscription without unmounting. When false, reading stops updating but calibration and interference observation continue (so you can still show warnings). |
filterDegrees, smoothingAlpha, declination, and pauseOnBackground map to global state on NitroCompass — if multiple hooks set them, last-write-wins.
| Field | Type | Description |
|---|---|---|
reading |
CompassSample | null |
Latest emitted sample ({ heading, accuracy, fieldStrengthMicroTesla }), or null until the first arrives. Heading is true-north when declination is set, magnetic otherwise. |
quality |
AccuracyQuality | null |
Coarse calibration bucket — 'high', 'medium', 'low', or 'unreliable'. null until the first transition. Show your own calibration UI on 'unreliable'. |
interfering |
boolean |
true while external magnetic interference is detected (field-magnitude band check + Android bias-jump grace). See Magnetic interference. |
hasCompass |
boolean |
Hardware availability — read once on first render. Render a fallback when false. |
diagnostics |
SensorDiagnostics | undefined |
Which sensor backs the readings on this device (magnetometer on Android, coreLocation on iOS). Useful for explaining quality differences. |
permission |
PermissionStatus |
Latest platform permission state. Always 'granted' on Android. On iOS may transition 'unknown' → 'granted'/'denied' after requestPermission() resolves. |
getCurrentHeading |
() => CompassSample | undefined |
Synchronous read of the most recent sample. Stable identity across renders. Useful inside event handlers without forcing a re-render. |
recalibrate |
() => void |
Force a best-effort sensor recalibration (Refresh button behind a calibration banner). Stable identity. See NitroCompass.recalibrate(). |
setLocation |
(lat: number, lon: number) => void |
Tighten the interference gate using expectedField ± 15 µT (Android only). No-op on iOS. Stable identity. |
requestPermission |
() => Promise<PermissionStatus> |
Prompt the platform permission dialog (iOS) and update the hook's permission field. Resolves with the resulting status. Stable identity. |
For non-React state managers, lower-level addHeadingListener(cb): () => void, addCalibrationListener(cb): () => void, and addInterferenceListener(cb): () => void are also exported. They are reference-counted: the first heading listener calls start(), the last unsubscribe calls stop(). Mixing these helpers with direct NitroCompass.start() / setOnCalibrationNeeded() / setOnInterferenceDetected() will clobber the multiplex's internal callback slot — pick one path.
useCompass() returns React state, so each sample re-renders the consumer — fine for a numeric readout, but a rotating dial driven that way will jitter on faster filter values. For 60 fps animations, subscribe with addHeadingListener and write directly into a Reanimated shared value on the UI thread:
import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'
import { addHeadingListener } from 'react-native-nitro-compass'
function Dial() {
const angle = useSharedValue(0)
const last = useRef(0)
useEffect(() => addHeadingListener(({ heading }) => {
// unwrap so 359° → 1° animates +2°, not -358°
const wrapped = ((last.current % 360) + 360) % 360
let delta = heading - wrapped
if (delta > 180) delta -= 360
else if (delta < -180) delta += 360
last.current += delta
angle.value = withTiming(last.current, { duration: 80, easing: Easing.out(Easing.quad) })
}), [angle])
const style = useAnimatedStyle(() => ({ transform: [{ rotate: `${-angle.value}deg` }] }))
return <Animated.View style={[styles.dial, style]}>{/* ticks */}</Animated.View>
}The same pattern is used in example/components/Compass.tsx.
-
iOS: requires
NSLocationWhenInUseUsageDescriptioninInfo.plist.CLLocationManageronly emits headings when location permission is granted. - Android: no permission required for the magnetometer or accelerometer.
A bare React Native CLI app under example/ (RN 0.85.3, New Arch enabled) consumes the library via a local symlink. It demos the full surface — useCompass() for the readout, calibration / interference banners, and a Reanimated-driven dial that subscribes via addHeadingListener so the rotation runs entirely on the UI thread. Use it to test changes on a real device — the iOS Simulator has no compass and the Android emulator's magnetometer is faked.
First-time setup:
cd example
npm install # symlinks ../ as react-native-nitro-compass
cd ios && bundle install && bundle exec pod install && cd ..Run on a device:
# Terminal 1 — Metro
npm start
# Terminal 2 — build & launch
npm run ios -- --device # physical iPhone
npm run android # physical device or emulatorIf you change the Nitrogen spec or any native source, regenerate and rebuild:
# from the repo root
npm run codegen
# then in example/
cd ios && bundle exec pod install && cd .. # iOS only
npm run ios # or npm run androidThe example imports NitroCompass directly from the workspace src/ (via Metro watchFolders), so editing TypeScript only requires a Metro reload.
The Android sensor pattern (raw mag + accel fusion via getRotationMatrix, surface-rotation remapping, getOrientation extraction, EMA on (sin θ, cos θ)) is adapted from the MIT-licensed Andromeda sensor library by Kyle Corry, which powers the Trail Sense wilderness navigation app.
Bootstrapped with create-nitro-module.
MIT — see LICENSE.