Guide

Platform-specific code

One nsh bundle runs on iOS, Android and Mac Catalyst. Most of an app needs no platform checks at all: elements, styles, layout, components and modules behave the same everywhere, and the components of nsh/ui/mobile are drawn by nsh rather than borrowed from each platform, so they look the same on both. When something really is specific to one platform, nsh gives it a clear place, so platform code stays visible and contained:

WhereFor
ios={{…}}, android={{…}}, desktop={{…}}Props only one platform has.
nsh/ios, nsh/androidAPIs only one platform has.
Platform.OS, Platform.selectChoosing values or code per platform.
ios: / android: classesStyles per platform.

#Platform-only props

Props that exist on one platform only are grouped in an object named after it. Each element accepts ios, android and desktop (macOS, Windows, Linux); on other platforms the object is ignored, and on its own platform only its keys are applied.

<View
  onPress={open}
  activeOpacity={0.6}
  android={{ ripple: { color: "rgba(0,0,0,0.12)" }, elevation: 4 }}
  style={{ padding: 16, borderRadius: 12, backgroundColor: "#ffffff" }}
>
  <Text>Open</Text>
</View>

<Input
  placeholder="Code"
  ios={{ textContentType: "oneTimeCode" }}
/>

<Modal visible={open()} ios={{ presentationStyle: "pageSheet" }} android={{ statusBarTranslucent: true }}>
  …
</Modal>

The objects are reactive like any prop; a key removed from the object is cleared.

What exists today:

PropOnDescription
android={{ ripple }}every elementThe material ripple while pressed: { color?, borderless?, radius?, foreground? }.
android={{ elevation }}every elementThe material shadow, in points, from the view’s shape.
android={{ accessibilityLiveRegion }}every element"none", "polite" or "assertive": TalkBack announces the view’s changes.
android={{ statusBarTranslucent }}ModalThe modal’s content reaches under the status bar.
android={{ backgroundColor }}StatusBarThe status bar’s color (before Android 15’s edge-to-edge).
ios={{ inputAccessoryViewID }}<Input>The InputAccessoryView shown over the keyboard.
ios={{ textContentType }}<Input>iOS’s names for autofill types.
ios={{ headerLargeTitle }}, ios={{ headerBackTitle }}ScreenA large title; the back button’s label.
ios={{ presentationStyle }}, ios={{ supportedOrientations }}Modal"pageSheet" / "formSheet" sheets; allowed orientations.
ios={{ allowsBackForwardNavigationGestures }}WebViewSwipe back and forward in the page history.

desktop={{…}} is accepted everywhere and reserved for desktop-only props; none exist yet. The types (IOSProps, AndroidProps, DesktopProps and their per-element forms) tell your editor what each element takes.

Cross-platform props whose effect differs slightly are not grouped: Alert.alert’s cancelable option exists on both (iOS alerts are simply never dismissed by a tap outside), and pagingEnabled, refreshing, hitSlop and the rest work on every platform.

#Platform modules

nsh/ios and nsh/android hold the APIs only one platform has. Both can be imported on every platform, so one bundle runs everywhere: on the other platform their functions do nothing and their components render nothing. Each exports isAvailable, which is true only on its own platform.

#nsh/android

ExportDescription
Toast.show(message, duration?)A toast at the bottom of the screen; "short" (default) or "long".
BackHandler.addEventListener("hardwareBackPress", listener)Listens to the back button after modals and stacks; return true when handled. Returns { remove() }.
BackHandler.exitApp()Leaves the app.
setStatusBarBackgroundColor(color, animated?)The status bar’s color under what StatusBar components set (before edge-to-edge, API 35).
isAvailabletrue on Android.
import { cleanup } from "nsh";
import { BackHandler, Toast } from "nsh/android";
import { Platform } from "nsh/device";
import { Alert } from "nsh/ui/mobile";

function saved() {
  if (Platform.OS === "android") Toast.show("Saved");
  else Alert.alert("Saved");
}

function Player() {
  // Back leaves full screen before it leaves the screen.
  const sub = BackHandler.addEventListener("hardwareBackPress", () => {
    if (!fullScreen()) return false;
    fullScreen.set(false);
    return true;
  });
  cleanup(() => sub.remove());
  // …
}

The back button is handled by the top modal first, then the top navigation stack, then BackHandler listeners (most recent first), and the app exits when nothing handled it. See Navigation.

#nsh/ios

ExportDescription
InputAccessoryViewA bar over the keyboard of the inputs naming its nativeID in ios={{ inputAccessoryViewID }}.
isAvailabletrue on iOS and Mac Catalyst.

See Forms and the keyboard.

#The status bar

The status bar is set with StatusBar from nsh/ui/mobile, on both platforms: barStyle ("default", "light-content", "dark-content"), hidden and animated, with Android’s color in android={{ backgroundColor }}. The most recently mounted StatusBar wins, prop by prop, so a screen can render one for itself and the previous settings come back when it goes away. StatusBar.setBarStyle() and StatusBar.setHidden() set the base values imperatively.

import { View } from "nsh";
import { StatusBar } from "nsh/ui/mobile";

function PhotoViewer() {
  return (
    <View style={{ flex: 1, backgroundColor: "#000000" }}>
      <StatusBar barStyle="light-content" animated />
      …
    </View>
  );
}

In an app embedded in a native one, the host app’s view controller decides the status bar on iOS. An app that declares "capabilities" in nsh.json needs "statusBar": true to change it.

#Platform.OS and Platform.select

Platform from nsh/device tells you where the app runs: Platform.OS is "ios", "android", "macos", "windows" or "linux" (Mac Catalyst is "ios", with Platform.isMacCatalyst). See Device and environment for Version, isPad and the rest.

import { Platform } from "nsh/device";

if (Platform.OS === "android") {
  // …
}

#Platform.select

Platform.select(spec) returns the value for this platform. It looks for the OS’s own key, then mobile (iOS and Android) or desktop (macOS, Windows, Linux), then native (every platform), then default:

const styles = {
  card: {
    borderRadius: 12,
    backgroundColor: "#ffffff",
    ...Platform.select({
      ios: { shadowColor: "#000000", shadowOpacity: 0.12, shadowRadius: 8, shadowOffset: { width: 0, height: 2 } },
      android: { borderWidth: 1, borderColor: "#e5e7eb" },
    }),
  },
};

const hint = Platform.select({ ios: "Swipe down to close", android: "Press back to close", default: "Close" });

It returns undefined when nothing matches and there is no default.

#Rendering different components

Choose components the same way, at module level when the choice never changes:

const Spinner = Platform.select({ ios: IosSpinner, default: MaterialSpinner })!;

or inline with <If>:

<If when={Platform.OS === "ios"} else={<Text>Android has no input accessories.</Text>}>
  <InputAccessoryView nativeID="bar">…</InputAccessoryView>
</If>

#Platform variants in Tailwind

The ios: and android: variants apply classes on one platform:

<View class="rounded-xl bg-white p-4 ios:shadow-md android:border android:border-zinc-200">…</View>
<Text class="text-base android:font-medium">Title</Text>

#Prefer shared behavior

Before reaching for a platform check, consider whether the difference matters to your users. Elements and styles already map to each platform’s native behavior where it should differ (scroll physics, text rendering, keyboards, accessibility), while staying the same where users expect consistency (your app’s own look). Platform checks are best kept for platform conventions users rely on: Android’s back button and toasts, iOS’s sheets and large titles, autofill names, ripple versus opacity feedback.