☕ If this library has helped you, consider buying me a coffee! Your support keeps development going.
Authoritative Apple runtime detection for React Native and Expo. Tell an iOS app running on macOS (Designed for iPad) apart from a real iPad, and read the raw UIUserInterfaceIdiom — the things Platform and expo-device can't reliably answer. iOS only, built with the Expo Modules API.
import { isIOSAppOnMac, isIPadOS, getInterfaceIdiom } from 'react-native-apple-runtime';
isIOSAppOnMac(); // true only on a Mac (Designed for iPad)
isIPadOS(); // true only on a genuine iPad
getInterfaceIdiom(); // 'phone' | 'pad' | 'mac' | 'tv' | 'carplay' | 'vision' | 'unspecified'When your iPhone/iPad app runs on an Apple silicon Mac via Designed for iPad, UIKit reports the interface idiom as .pad — exactly like a real iPad. So:
Platform.OSis'ios'on a Mac, andPlatform.isPadistrueon both a Mac and a real iPad.expo-deviceexposes neitherisiOSAppOnMacnor the interface idiom, so it can't separate the two either.
That ambiguity matters: some iOS-only URL schemes don't resolve on a Mac, haptics no-op, and you may want a different layout. The only authoritative signal is Apple's ProcessInfo.processInfo.isiOSAppOnMac, which is not surfaced to JavaScript by the React Native or Expo SDKs. This module bridges it — plus the raw idiom — in ~30 lines of Swift, with zero runtime cost.
Platform |
expo-device |
react-native-apple-runtime | |
|---|---|---|---|
iOS-app-on-Mac (isiOSAppOnMac) |
❌ | ❌ | ✅ |
| Real iPad vs Mac-as-iPad | ❌ | ❌ | ✅ |
UIUserInterfaceIdiom |
❌ | ❌ | ✅ |
npm install react-native-apple-runtime
# or
yarn add react-native-apple-runtime
# or
bun add react-native-apple-runtimeThen rebuild the native project so autolinking picks up the module:
npx expo prebuild
npx expo run:iosThere is no config plugin and no entitlements to set up — the module only reads ProcessInfo and UIDevice, so installing and rebuilding is all that's required.
- Expo
>= 51.0.0 - React Native
>= 0.74.0 - iOS only (Android and other platforms are safely inert — see Platform behavior)
- A native build. The values are real on a device/simulator build; in Jest the module is absent and every helper returns its inert default.
import { isIOSAppOnMac, isIPadOS, getInterfaceIdiom } from 'react-native-apple-runtime';
import type { InterfaceIdiom } from 'react-native-apple-runtime';type InterfaceIdiom =
| 'phone'
| 'pad'
| 'mac'
| 'tv'
| 'carplay'
| 'vision'
| 'unspecified';The lower-cased UIUserInterfaceIdiom. An iOS app running on a Mac reports 'pad' here (same as a real iPad) — use isIOSAppOnMac() to disambiguate.
function isIOSAppOnMac(): boolean;true only when this iOS binary is running on macOS via Designed for iPad (an iPhone/iPad app on an Apple silicon Mac). Backed by Apple's authoritative ProcessInfo.processInfo.isiOSAppOnMac. Returns false on real iOS/iPadOS devices, on Mac Catalyst, on Android, and in tests.
if (isIOSAppOnMac()) {
// Running on a Mac — e.g. third-party browser URL schemes won't resolve here,
// so fall back to the system default browser.
}function isIPadOS(): boolean;true only on a genuine iPad running iPadOS. Defined as getInterfaceIdiom() === 'pad' && !isIOSAppOnMac(), because an iOS app on a Mac also reports the 'pad' idiom and must be excluded. Returns false on iPhone, on Mac, on Android, and in tests.
if (isIPadOS()) {
// Genuine iPad — safe to enable an iPad-specific multi-column layout.
}function getInterfaceIdiom(): InterfaceIdiom;The raw interface idiom of the current process, from UIDevice.current.userInterfaceIdiom. Returns 'unspecified' on Android and whenever the native module is unavailable.
switch (getInterfaceIdiom()) {
case 'phone': /* iPhone */ break;
case 'pad': /* iPad OR an iOS app on Mac — check isIOSAppOnMac() */ break;
case 'mac': /* Mac Catalyst with the Mac idiom */ break;
}| Environment | isIOSAppOnMac() |
isIPadOS() |
getInterfaceIdiom() |
|---|---|---|---|
| iPhone | false |
false |
'phone' |
| iPad (real device) | false |
true |
'pad' |
| iOS app on Mac (Designed for iPad) | true |
false |
'pad' |
| Android | false |
false |
'unspecified' |
| Jest / no native runtime | false |
false |
'unspecified' |
Every helper is safe to call on any platform: on non-iOS, and when the native module is absent (such as in Jest), they return the inert defaults above instead of throwing. The native module is loaded via requireOptionalNativeModule, so missing-module environments never crash.
The native module exposes two constants, evaluated once when the module loads:
isiOSAppOnMac—ProcessInfo.processInfo.isiOSAppOnMac. This is Apple's own flag and the only reliable way to detect the Designed-for-iPad-on-Mac case. (ProcessInfo.isMacCatalystAppistruefor both Mac Catalyst and Designed-for-iPad, so it can't make this distinction;isiOSAppOnMaccan.)interfaceIdiom—UIDevice.current.userInterfaceIdiom, lower-cased to a stable string.
Because they are exposed as Expo Module constants, reading them from JavaScript is a synchronous property access with no bridge round-trip and no measurable runtime cost.
MIT © Bogdan Georgian Alexa