Oliver Edis

Getting started with visionOS and ViroReact

Getting started with visionOS and ViroReact: Apple Vision Pro with ViroReact branding

Apple Vision Pro is now a first-class ViroReact target. If you already ship on phones or Quest, the pitch is the same one we keep making: same React Native codebase, another platform, no rewrite.

This is a practical setup guide based on our current visionOS docs. It is Expo-only for now. We have not verified bare React Native yet, and we would rather say that upfront than let you discover it the hard way.

What you need

  • Xcode with the visionOS SDK (see the current VISIONOS_SETUP guide for the exact versions we validated against)
  • Expo SDK 57
  • @reactvision/react-viro 3.0.0 or newer
  • @reactvision/react-native-visionos installed alongside react-native (visionOS is an out-of-tree RN platform)

visionOS apps have an ios/ folder and a visionos/ folder that share the same JavaScript. Important gotcha: Platform.OS is "ios" on visionOS. The fork keeps the iOS identity. Use isVisionOS from Viro to tell them apart, never Platform.OS.

1. Add the Expo plugins

{
  "expo": {
    "plugins": [
      "@reactvision/react-viro",
      "@reactvision/react-viro/plugins/withViroVisionOS"
    ]
  }
}

2. Create the visionos/ folder (one time)

npx @react-native-community/cli@latest init MyApp \
  --template @reactvision/visionos-template \
  --directory visionos --skip-install

Use @reactvision/visionos-template. It is built for React Native 0.86 and already points at @reactvision/react-native-visionos, so you do not have to reconcile versions by hand. After the folder exists, expo prebuild manages it.

3. Prebuild, install, open Xcode

npm install --save-dev @react-native-community/cli @callstack/out-of-tree-platforms
npx expo prebuild
npm install
cd visionos && pod install
open visionos/MyApp.xcworkspace

The first line matters. The visionos/ Podfile autolinks through @react-native-community/cli, which Expo apps do not ship by default, and @callstack/out-of-tree-platforms is what Metro needs to find the visionOS platform. The plugin warns if they are missing, but installing them first is cheaper than reading a CocoaPods wall of text.

Debug builds do not embed the JS bundle (same as Expo's iOS Debug path), so Metro needs to be running and reachable from the headset.

4. Use ViroXRSceneNavigator

Same component as phones and Quest:

import { ViroXRSceneNavigator } from "@reactvision/react-viro";

<ViroXRSceneNavigator
  arInitialScene={{ scene: PhoneARScene }}
  vrInitialScene={{ scene: HeadsetScene }}
/>

On visionOS the navigator opens the ImmersiveSpace when it mounts and closes it on unmount. Mount it behind a button if you want the user to choose:

const [xrOpen, setXrOpen] = useState(false);

return (
  <View style={{ flex: 1 }}>
    <Button title="Enter XR" onPress={() => setXrOpen(true)} />
    {xrOpen && (
      <ViroXRSceneNavigator vrInitialScene={{ scene: HeadsetScene }} />
    )}
  </View>
);

Root your scene in ViroScene, not ViroARScene

The visionOS renderer excludes the AR subsystem. An AR-rooted scene fails at mount. That is why visionOS reads vrInitialScene and never arInitialScene. A Quest VR scene is already a ViroScene, so one headset scene usually serves both.

The window keeps your 2D UI

On visionOS the navigator has no layout footprint. Content lives in the ImmersiveSpace. Put ordinary React Native UI in the window: a title, controls, an exit button. An empty window renders black. That looks like a bug. It is just an empty window.

5. A minimal headset scene

import {
  ViroScene,
  ViroSkyBox,
  ViroAmbientLight,
  ViroDirectionalLight,
  ViroBox,
  ViroMaterials,
} from "@reactvision/react-viro";

ViroMaterials.createMaterials({
  magenta: { diffuseColor: "#FF3DAE" },
});

export default function HeadsetScene() {
  return (
    <ViroScene>
      <ViroSkyBox color="#0B1020" />
      <ViroAmbientLight color="#FFFFFF" intensity={180} />
      <ViroDirectionalLight color="#FFFFFF" direction={[0, -1, -0.4]} />
      <ViroBox
        position={[0, 0, -2]}
        scale={[0.3, 0.3, 0.3]}
        materials={["magenta"]}
      />
    </ViroScene>
  );
}

6. Input is a pinch

There are no controllers. A tap is a pinch. Your scene still gets onClick, onHover and drag events. The ray is aimed from the head through the hand by default (steadier than finger-aim at the moment of pinch). You can tune it at runtime:

import { setInputTuning } from "@reactvision/react-viro";

setInputTuning({
  rayOrigin: "head", // or "finger"
  smoothing: 0.5,
  hoverHysteresis: 0.02,
  coneAngle: 0.03,
});

That call is a no-op on every other platform, so it needs no isVisionOS guard.

7. Immersion style

<ViroXRSceneNavigator
  vrInitialScene={{ scene: HeadsetScene }}
  visionOSImmersionStyle="mixed" // or "full"
/>
  • mixed (default): virtual content over passthrough. Closest analogue to phone AR.
  • full: fully virtual, passthrough hidden.

Do not ask for "progressive". Supporting it needs a different presentation path; declaring it today aborts shortly after the space opens.

Honest limitations

We would rather list these than surprise you:

  • No AR components on visionOS. ViroARScene, planes, image markers and the rest of the AR set are not available. Unsupported components warn and render nothing rather than crashing.
  • GLSL shader modifiers do not work. The Metal path injects modifier bodies as-is. GLSL spellings fail at compile time. Avoid custom GLSL modifiers and polylines that depend on them for now.
  • Video, audio, camera texture and object detection are not available on visionOS yet.
  • ViroButton, ViroVirtualButton and ViroVirtualJoystick draw but do not fire input yet.
  • CJK glyphs render as tofu with the bundled font; accented Latin is fine.
  • Expo only for now. Bare React Native is unverified.

Primitives, text, PBR with image-based lighting, particles and property animations are in good shape on device. StudioSceneNavigator works on visionOS via the same ViroXRSceneNavigator path.

Platform checks

import { isVisionOS, enterImmersiveSpace, exitImmersiveSpace } from "@reactvision/react-viro";

if (isVisionOS()) {
  // visionOS-specific UI or immersion control
}

Most apps should let ViroXRSceneNavigator own the ImmersiveSpace. Drive it by hand or let the navigator do it, not both.

Where next

Full detail lives in VISIONOS_SETUP in the ViroReact docs (and via our MCP server if you prefer asking an agent). Join us on Discord if something in this path bites you.

Same codebase. Another platform. That is the whole idea.

Oli