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
| Xcode | 26.6 with the visionOS 26.5 SDK |
| Deployment target | visionOS 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 |
| Expo | SDK 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)
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-templateis not on npm yet. Once it is, this becomes--template @reactvision/visionos-template. Both install the same thing.Callstack's
@callstack/visionos-templatealso 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:visionosadded toresolver.platforms, and ViroReact's asset extensions (glb,gltf,hdr,obj,mtl,vrx) added toassetExts— 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) invisionos/Podfile post_installhooks: UIKit into every pod's prefix header, the fmt fix, C++20, Hermes JSI headersENV['RCT_USE_PREBUILT_RNCORE']andENV['RCT_USE_RN_DEP']set to'0'moduleName: "main"and theImmersiveSpacescene inApp.swiftUIApplicationSupportsMultipleScenesinInfo.plist- Expo's entry point and the
ReactAppDependencyProviderimport inAppDelegate.swift - five third-party patches,
postinstall: patch-package, andpatch-packageas a devDependency - the
BlurView/LinearGradientcompat 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:
-allowProvisioningUpdatesis 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=1for Debug, matching whatexpo prebuildwrites 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 atexpo/scripts/resolveAppEntry. Bundling stays with the React Native CLI, because the fork's script passes--resolver-optionfor the visionos platform extension and@expo/clirejects 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
| Check | Requirement | If it fails |
|---|---|---|
| Project type | Expo (managed or prebuild) | A bare React Native CLI app is not supported yet. Migrate to Expo prebuild first. |
| Expo SDK | 57 | Upgrade before adding the plugin. |
| ViroReact | @reactvision/react-viro ≥ 3.0.0 | npm install @reactvision/react-viro@latest |
| Scene root | ViroScene, not ViroARScene | See 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
visionos/ and rebuildSame 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.
| Area | Components |
|---|---|
| AR | ViroARScene, ViroARPlane, ViroARImageMarker, ViroARObjectMarker, and the rest of the AR set |
| Video | Viro360Video, ViroVideo, ViroMaterialVideo |
| Audio | ViroSound, ViroSpatialSound, ViroSoundField |
| Camera | ViroCameraTexture, ViroObjectDetector |
| Other | ViroAnimatedImage, 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
isVisionOS, never Platform.OSPlatform.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
| Platform | Host | Scene prop | Renders in the RN view |
|---|---|---|---|
| iOS / Android | ViroARSceneNavigator | arInitialScene | yes, the camera feed |
| Meta Quest | VRActivity (separate React host) | vrInitialScene | no — renders null |
| Apple Vision Pro | ImmersiveSpace (same React host) | vrInitialScene | no — 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
ViroPolylineand 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.
RCTMainWindowcreates itsWindowGroupwithout an identifier, and without one there is noopenWindow(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
| Symptom | Cause |
|---|---|
no such module 'React' in RCTRootViewRepresentable.swift | React 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 spinner | AppDelegate.swift is asking Metro for index. Expo serves .expo/.virtual-metro-entry |
| The ImmersiveSpace never opens and nothing is thrown | UIApplicationSupportsMultipleScenes is false in Info.plist |
[Viro] ViroARScene is not supported on Apple Vision Pro and an empty scene | The scene is rooted in ViroARScene. Use ViroScene |
| The window is a black panel | The window has no content of its own. The navigator draws in the ImmersiveSpace, not there |
| A pod fails on a missing UIKit type | The 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 |
Updated about 1 hour ago