HomeGuidesChangelogDiscussions
Hire a ReactVision Expert 🛠️Launch Studio 🖥️Sponsor on GitHub ❤️Log In
Guides

visionOS Setup Guide

ViroReact runs on visionOS through an ImmersiveSpace driven by ViroXRSceneNavigator — the same component you already use for iOS, Android and Meta Quest. Most of the code you write is identical across all four.

Preview status. The scene renders, input reaches JavaScript, and the enter/exit cycle is clean — all confirmed on an Apple Vision Pro. GPU cost sits at 41–53% of the 90 Hz budget at p95 on a modest test scene. The component set has not been fully swept, so expect gaps and report them.

Requirements

Xcode26.6 with the visionOS 26.5 SDK
Deployment targetvisionOS 26.0 — the renderer calls LayerRenderer.Frame.queryDrawables() and Drawable.computeProjection(viewIndex:) with no availability fallback
React Native@reactvision/react-native-visionos 0.86.4, installed alongside react-native
ExpoSDK 57
ViroReact@reactvision/react-viro 3.0.0 or newer

Expo only, for now. The config plugin and the project template both assume an Expo app. A bare React Native CLI app is not supported yet - not because anything is known to be broken there, but because nothing has been verified there.

visionOS is an out-of-tree React Native platform. Your app ends up with an ios/ folder and a visionos/ folder consuming the same JavaScript. Platform.OS is "ios" on visionOS — the fork keeps the iOS identity — so use isVisionOS from ViroReact to tell them apart, never Platform.OS.


Part 1 — Setting up as part of a new project

1. Create the project

Start from the official Expo + TypeScript starter kit, which already has ViroReact wired in:

git clone https://github.com/ReactVision/expo-starter-kit-typescript MyApp
cd MyApp
npm install

Confirm you are on Expo SDK 57 and @reactvision/react-viro 3.0.0 or newer before continuing — the visionOS plugin requires both.

2. Configure the Expo plugins

Add both plugins to app.json. The second one is what does all the visionOS work:

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

3. Create the visionos/ folder (one time)

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

The ReactVision template is built for React Native 0.86 and already points at @reactvision/react-native-visionos, so there is nothing to reconcile by hand. Once the folder exists, expo prebuild manages it from then on.

Why a GitHub specifier and not a package name. @reactvision/visionos-template is not on npm yet. Once it is, this becomes --template @reactvision/visionos-template. Both install the same thing.

Callstack's @callstack/visionos-template also exists and is what this guide used to recommend, but it is published at 0.79.6 against a 0.86 fork — the generated project then needs reconciling with 0.86 by hand, which is exactly the step the ReactVision template removes.

4. Prebuild, install, build

npm install --save-dev @react-native-community/cli @callstack/out-of-tree-platforms
npx expo prebuild
npm install                 # applies the patches the plugin just added
cd visionos && pod install
open visionos/MyApp.xcworkspace

Why the first line. The visionos/ Podfile autolinks through @react-native-community/cli, which an Expo app does not have — Expo ships its own CLI — and without it pod install fails inside CocoaPods with a wall of text that names the package only in passing. @callstack/out-of-tree-platforms is what the Metro resolver uses to find the visionOS platform. The plugin warns about both if they are missing, but it is cheaper to install them first.

No environment prefix is needed on pod install. The plugin writes the two React Native source flags into the Podfile itself.

What the plugin does for you
  • the visionOS platform resolver in metro.config.js, plus two things that are easy to miss: visionos added to resolver.platforms, and ViroReact's asset extensions (glb, gltf, hdr, obj, mtl, vrx) added to assetExts — without the latter, <ViroLightingEnvironment source={require('./env.hdr')} /> fails the bundle outright with "Unable to resolve ./env.hdr" before a frame is drawn
  • the three pods (ViroKit, ViroReact, ViroReactUI) in visionos/Podfile
  • post_install hooks: UIKit into every pod's prefix header, the fmt fix, C++20, Hermes JSI headers
  • ENV['RCT_USE_PREBUILT_RNCORE'] and ENV['RCT_USE_RN_DEP'] set to '0'
  • moduleName: "main" and the ImmersiveSpace scene in App.swift
  • UIApplicationSupportsMultipleScenes in Info.plist
  • Expo's entry point and the ReactAppDependencyProvider import in AppDelegate.swift
  • five third-party patches, postinstall: patch-package, and patch-package as a devDependency
  • the BlurView / LinearGradient compat shims

Three of these fail silently when missing, which is why they are automated rather than documented as steps: without the RN flags you get no such module 'React' inside React Native's own source; without UIApplicationSupportsMultipleScenes the ImmersiveSpace never opens and nothing is thrown; without the Expo entry point the app sits on its loading spinner forever.

5. Build for the headset

cd visionos
xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Debug \
  -destination 'generic/platform=visionOS' \
  DEVELOPMENT_TEAM=YOUR_TEAM CODE_SIGN_STYLE=Automatic \
  -allowProvisioningUpdates build

Three things here are not obvious:

  • -allowProvisioningUpdates is required, not optional. Without it signing fails even with a valid team and valid certificates. It reads as a certificate problem and is not one.
  • Debug does not embed the JS bundle. The plugin sets SKIP_BUNDLING=1 for Debug, matching what expo prebuild writes for iOS, so Metro must be running and reachable from the headset. Embedding in Debug also trips Metro's "Unexpected module with full source map found".
  • The bundling phase resolves Expo's entry point. The bare template asks for index.js, which an Expo project does not have; the plugin points it at expo/scripts/resolveAppEntry. Bundling stays with the React Native CLI, because the fork's script passes --resolver-option for the visionos platform extension and @expo/cli rejects that flag.

If your project uses the @/ path alias: device builds run their own Metro from the Xcode phase and read only metro.config.js — tsconfig paths come from the Expo dev server, not from that instance. The plugin's metro patch resolves @/ for both.

6. Write your first scene

Root it in ViroScene and mount it through ViroXRSceneNavigator:

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>
  );
}
import { ViroXRSceneNavigator } from "@reactvision/react-viro";

<ViroXRSceneNavigator
  arInitialScene={{ scene: PhoneARScene }}   // iOS / Android
  vrInitialScene={{ scene: HeadsetScene }}   // Quest and visionOS
  visionOSImmersionStyle="mixed"             // "mixed" (default) or "full"
/>

ViroSkyBox takes a solid color as well as a cube map, so a complete scene needs no bundled assets to get your first frame.


Part 2 — Adding visionOS to an existing project

Everything in Part 1 applies; this section is about what an app that already ships iOS and Android has to change. Work through the four checks first — they are cheaper to answer now than to debug after a build.

Preflight checks

CheckRequirementIf it fails
Project typeExpo (managed or prebuild)A bare React Native CLI app is not supported yet. Migrate to Expo prebuild first.
Expo SDK57Upgrade before adding the plugin.
ViroReact@reactvision/react-viro ≥ 3.0.0npm install @reactvision/react-viro@latest
Scene rootViroScene, not ViroARSceneSee Refactor your scene root below.

1. Add the plugin alongside your existing config

Keep "@reactvision/react-viro" where it is and add the visionOS plugin after it:

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

Your existing ios/ and android/ folders are untouched — visionOS is additive.

2. Generate visionos/ and rebuild

Same as Part 1, steps 3–5. expo prebuild will pick up the new folder and manage it from then on.

3. Refactor your scene root

Your headset scene must be rooted in ViroScene, not ViroARScene. The visionOS renderer excludes the AR subsystem entirely, so VRTARScene has no view manager and an AR-rooted scene fails at mount with "View config not found for component VRTARScene".

This is why the navigator reads vrInitialScene and never arInitialScene on visionOS. If you already ship Quest, your VR scene is a ViroScene and one scene usually serves both headsets — pass the existing phone AR scene as arInitialScene and the headset scene as vrInitialScene on the same navigator.

If you are coming straight from mobile AR, split the scene: keep the plane detection, image markers and anchoring in the ViroARScene for iOS/Android, and extract the content that can stand on its own into a ViroScene for the headset.

4. Move your 2D UI into the window

On visionOS the navigator renders with no layout footprint: its content lives in the ImmersiveSpace, so it takes no space in the React Native window and paints nothing there. Put your ordinary React Native UI — a title, controls, an exit button — in that window. An empty React Native window renders black, which looks like a bug and is simply an empty window.

The navigator opens the ImmersiveSpace when it mounts and closes it when it unmounts. Mount it behind a button if the user should choose:

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

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

5. Audit your components against the support list

The visionOS renderer is Metal-only and excludes several subsystems. These components have no view manager on visionOS. They no longer take the app down — each checks the platform in JS, warns once with [Viro] <name> is not supported on Apple Vision Pro, and renders nothing — so you get a console warning and a gap in the scene rather than a crash.

AreaComponents
ARViroARScene, ViroARPlane, ViroARImageMarker, ViroARObjectMarker, and the rest of the AR set
VideoViro360Video, ViroVideo, ViroMaterialVideo
AudioViroSound, ViroSpatialSound, ViroSoundField
CameraViroCameraTexture, ViroObjectDetector
OtherViroAnimatedImage, ViroPortal, HUD components, Viro3DSceneNavigator, ViroVRSceneNavigator

Viro3DSceneNavigator deserves a note: it is the OpenGL presentation path — it builds an EAGLContext and hosts a VROViewScene — and neither exists on visionOS. The ImmersiveSpace is the presentation path instead, which is what ViroXRSceneNavigator uses.

What does work: PBR materials (IBL reimplemented for Metal), skeletal animations (GLB/GLTF only — the FBX loader is excluded), morph targets, particle effects, the Bullet3 physics engine, 360° images via setBackgroundSphere, and custom shaders translated to MSL.

6. Branch on isVisionOS, never Platform.OS

Platform.OS returns "ios" on visionOS. Anywhere your existing code branches on iOS and means phone, add the guard:

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

if (isVisionOS()) {
  // headset path
}

7. If you use Studio

StudioSceneNavigator delegates to ViroXRSceneNavigator, so it works on visionOS with no changes on your side. It takes the same path as Quest — pre-registering materials and animations before the renderer starts, then mounting the scene straight into the immersive space rather than pushing onto a loading scene.


Reference

Immersion style

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

"progressive" is not supported and must not be added to the ImmersiveSpace. The ImmersiveSpaceStyle type still accepts it, but it aborts at runtime. Declaring support for it changes the CompositorServices contract: presentation must then go through the drawable's render context, and encodePresent — which this renderer uses — is rejected outright. The process aborts a second or two after the space opens with "BUG IN CLIENT: cannot present drawable: need to use drawable render context when supporting progressive style."

Opening the space yourself

For cases where the space's lifetime is not the navigator's — a menu that should appear before any scene, a transition that dissolves rather than cuts — the calls are exported directly:

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

if (isVisionOS()) {
  await enterImmersiveSpace("mixed"); // or "full"
  // ...
  await exitImmersiveSpace();
}

Both return a boolean and resolve false off visionOS rather than throwing, so a call site shared with iOS needs no branching — though isVisionOS() is clearer about intent.

There is one space, and it has one owner. The navigator claims it on mount and only the claimant may close it, exactly as Quest treats VRActivity. Calling exitImmersiveSpace() under a mounted navigator closes the space the navigator still believes it owns, and mounting a second navigator does not open a second space — it takes over the one that exists. Drive it by hand or let the navigator do it, not both.

Input: how pointing works

There are no controllers. A tap is a pinch, and where it lands is decided by a ray the renderer casts from the user. Your scene receives the same onClick, onHover and drag events it would on any other platform, but two things about the ray change how you size targets.

The ray is aimed from the head, through the hand — not along the finger. Finger joints move whenever the hand does anything, most of all as a pinch begins, so a finger-aligned ray drifts at the exact moment of the tap. Head-through-hand matches how people physically point and is far steadier. rayOrigin: "finger" exists if you want the other behaviour; it points more precisely and more comfortably, and it is only the pinch that unsettles it. Try both with the headset on.

A pinch does not use the aim at the moment you pinch. The renderer keeps ~150 ms of recent aim and freezes to the oldest entry when a pinch starts — from before the finger began to curl. The click lands where you were pointing a moment earlier, which is where you meant. The reticle freezes with it.

Small targets get a cone, not just a ray. A precise hit is always tried first; only when the ray misses everything does the renderer widen to a cone of coneAngle radians (default 0.03, about 1.7°). A target roughly 2° wide is comfortable; anything much smaller will feel fussy however good the tracking is.

All four numbers are tunable at runtime — they can only be judged with a headset on, and a native rebuild is ten minutes:

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

setInputTuning({
  rayOrigin: "head",     // or "finger"
  smoothing: 0.5,        // 0 none, 1 heavy — live ray only, never the frozen aim
  hoverHysteresis: 0.02, // radians the ray must leave a target before hover drops
  coneAngle: 0.03,       // 0 disables the cone fallback
});

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

Platform behaviour summary

PlatformHostScene propRenders in the RN view
iOS / AndroidViroARSceneNavigatorarInitialSceneyes, the camera feed
Meta QuestVRActivity (separate React host)vrInitialSceneno — renders null
Apple Vision ProImmersiveSpace (same React host)vrInitialSceneno — zero-footprint

The Quest and visionOS rows differ in one way that matters: on Quest the scene is forwarded to another React host, so nothing stays mounted here; on visionOS the ImmersiveSpace shares this runtime, so the scene tree stays mounted and its nodes are what the renderer draws.

Known issues

  • Shader modifiers written in GLSL do not work. The Metal path translates them partially — it emits what it can and drops the declarations it cannot, producing shader source that fails to compile. Affects ViroPolyline and any custom shader modifier. No workaround today beyond avoiding them.
  • CJK glyphs render as tofu. Accented Latin is fine; Chinese, Japanese and Korean come out as empty boxes. This is charmap coverage in the bundled font, not a failure of the text system.
  • The window cannot be hidden while immersed. RCTMainWindow creates its WindowGroup without an identifier, and without one there is no openWindow(id:) to bring it back. Dismissing it would be a one-way trip, so the window stays and hosts your controls instead.
  • The component set is unverified. Around 28 components are classified as supported by reading the code, not by running them. Expect gaps, and report them.

Building with Xcode 27 / the iOS 27 SDK

That SDK makes the UIScene lifecycle mandatory, and an app without it crashes on launch with "UIScene life cycle is required" — on iOS, not only on visionOS. Expo backported opt-in support in [email protected]; upgrade Expo and add enableSceneSupport to expo-build-properties:

["expo-build-properties", { "ios": { "enableSceneSupport": true } }]

A bare UIApplicationSceneManifest in Info.plist is not enough — the app delegate has to adopt the lifecycle too, which is what the Expo flag does.

Troubleshooting

SymptomCause
no such module 'React' in RCTRootViewRepresentable.swiftReact core built from the prebuilt xcframework, which has no xros slice. The plugin sets RCT_USE_PREBUILT_RNCORE=0 in the Podfile; check it is there
App launches, reaches Metro, never leaves the loading spinnerAppDelegate.swift is asking Metro for index. Expo serves .expo/.virtual-metro-entry
The ImmersiveSpace never opens and nothing is thrownUIApplicationSupportsMultipleScenes is false in Info.plist
[Viro] ViroARScene is not supported on Apple Vision Pro and an empty sceneThe scene is rooted in ViroARScene. Use ViroScene
The window is a black panelThe window has no content of its own. The navigator draws in the ImmersiveSpace, not there
A pod fails on a missing UIKit typeThe CocoaPods prefix header has no visionOS case. The plugin's post_install puts UIKit back; check it ran
pod install fails with a CocoaPods autolinking error@react-native-community/cli is not installed. See step 4