HorizonOS Setup Guide
This guide walks through setting up HoriznOS (OpenXR) support in a React Native (with Expo) app using ViroReact.
Requirements
| Path | Minimum Expo SDK | Minimum React Native |
|---|---|---|
| AR (iOS / non-Quest Android) | 54 | 0.81 |
| VR (Meta Quest) | 55 | 0.83 |
The VR floor is non-negotiable. VRActivity and MainActivity share a single ReactHostImpl singleton and need to coordinate onHostResume/onHostPause across two surfaces. The skipActivityIdentityAssertion OnHostPause feature flag — required to suppress a hard-crash assertion during the racy MainActivity.onPause that follows VRActivity.onResume in FLAG_ACTIVITY_NEW_TASK ordering — is only honored on RN ≥ 0.83.
ViroXRSceneNavigator enforces this at runtime: on Quest hardware with RN < 0.83 it throws an actionable error and refuses to launch VR. AR continues to work on Expo 54.
If you only need AR, you can stay on Expo 54. If your app uses VR on Quest, upgrade to Expo 55 / RN 0.83.
Why two activities?
Horizon OS only grants exclusive OpenXR display access to an Activity that declares com.oculus.intent.category.VR. A normal RN Activity (portrait, 2D panel) can't also be the immersive VR Activity. So Quest apps run with two Activities sharing one ReactHost:
- MainActivity — your panel app (tabs, menus, navigation)
- VRActivity — immersive VR rendering, mounts
ViroQuestEntryPointas the"VRQuestScene"root
The library generates VRActivity for you (Expo plugin). "VRQuestScene" is registered via AppRegistry.registerComponent unconditionally whenever the library is imported — this is harmless on non-Quest builds because no VRActivity exists to call loadApp("VRQuestScene"). Most apps need no manual Quest-specific JS setup.
1. Configure the Expo plugin
In app.json / app.config.ts, add QUEST to the xRMode array of the @reactvision/react-viro plugin:
{
"expo": {
"plugins": [
[
"@reactvision/react-viro",
{
"android": {
"xRMode": ["AR", "QUEST"],
"questAppId": "YOUR_META_APP_ID"
}
}
]
]
}
}
questAppId is the numeric App ID from the Meta Developer Portal. It is written to AndroidManifest.xml as com.oculus.app_id meta-data, which tells Horizon OS the app name to display in system overlays. Without it the OS shows "App Name Unavailable" with a Quit button on first launch.
You can ship ["AR", "QUEST"] together — the same APK runs as a normal AR phone app on Android and as an immersive VR app on Quest. isQuest from the library distinguishes at runtime.
2. Run prebuild
npx expo prebuild --clean
This generates two things on Android:
android/app/src/main/java/<your-package>/VRActivity.kt- An
<activity>entry inAndroidManifest.xmldeclaringcom.oculus.intent.category.VR
VRActivity.kt is generated only if it doesn't already exist, so you can edit it after prebuild.
Upgrading from a pre-2.55.x react-viro? The new
VRActivity.kttemplate implements lifecycle-correctonResume/onPauseoverrides that driveReactHostImpl.onHostResume(VRActivity)and keepJavaTimerManager+ Metro Fast Refresh alive while VR is foreground. Because the plugin only writes the file when missing, delete the existingandroid/app/src/main/java/<your-package>/VRActivity.ktand re-runnpx expo prebuild --cleanto pick up the fix. Without this step you will see brokenrequestAnimationFrame/setTimeout, no first-launch animations, and Metro Fast Refresh that only works after one VR-out-and-back cycle.
3. Use ViroXRSceneNavigator in your panel
ViroXRSceneNavigator in your panelViroXRSceneNavigator is the single cross-platform entry point. On Quest it automatically sets the scene intent, launches VRActivity, and returns null from its own render (VRActivity owns the display). On iOS and non-Quest Android it renders ViroARSceneNavigator inline.
import { ViroXRSceneNavigator } from "@reactvision/react-viro";
export default function MyScreen() {
return (
<ViroXRSceneNavigator
arInitialScene={{ scene: MyARScene }}
vrInitialScene={{ scene: MyVRScene }}
style={{ flex: 1 }}
/>
);
}
arInitialScene— mounted on iOS / non-Quest Android viaViroARSceneNavigator.vrInitialScene— forwarded to VRActivity via the bridge; mounted insideViroVRSceneNavigatorin VRActivity.initialScene— shorthand when AR and VR use the same scene component.
Platform behavior summary
| Component | iOS | Android (non-Quest) | Meta Quest |
|---|---|---|---|
ViroXRSceneNavigator | AR (ViroARSceneNavigator) | AR (ViroARSceneNavigator) | Launches VRActivity, renders null in panel |
ViroVRSceneNavigator | (OVR/Cardboard only — not for Quest) | OVR/Cardboard VR | Used internally by VRActivity |
StudioSceneNavigator | AR + Studio content | AR + Studio content | VR + Studio content via VRActivity |
4. Write your VR scene
VR scenes use ViroScene as the root (not ViroARScene). Your vrInitialScene component and any subsequent pushed scenes should follow this pattern:
import {
ViroScene,
ViroAmbientLight,
ViroController,
Viro360Image,
} from "@reactvision/react-viro";
export function MyVRScene() {
return (
<ViroScene>
<ViroController controllerVisibility reticleVisibility />
<ViroAmbientLight color="#ffffff" intensity={400} />
<Viro360Image source={require("./assets/space.jpg")} />
{/* …your content… */}
</ViroScene>
);
}
StudioARScene already handles the Quest / non-Quest root automatically (isQuest ? <ViroScene> : <ViroARScene>), so Studio content works on both platforms with no per-scene changes.
5. Navigating between VR scenes
From inside a VR scene
ViroVRSceneNavigator passes a sceneNavigator prop to every scene it renders. Use it to push/pop directly — no bridge or ref needed:
export function MyVRScene({ sceneNavigator }: any) {
return (
<ViroScene>
<ViroNode onClick={() => sceneNavigator.push({ scene: DetailScene })}>
{/* … */}
</ViroNode>
</ViroScene>
);
}
All standard operations are available: push, pop, popN, replace, jump.
From panel-side code (via ref)
Use the ref returned by ViroXRSceneNavigator. On Quest every call is forwarded to the ViroVRSceneNavigator running in VRActivity via the bridge:
const navRef = useRef<any>(null);
// Push a new scene
navRef.current?.arSceneNavigator?.push({ scene: DetailScene });
// Pop back
navRef.current?.arSceneNavigator?.pop();
<ViroXRSceneNavigator ref={navRef} vrInitialScene={{ scene: MyVRScene }} />
arSceneNavigator is the unified ref accessor for both AR and VR paths (naming is historical — it works on Quest too).
6. Exit VR
There are three ways your VR session can end:
a. Programmatic exit from inside the VR scene
import { exitVRScene } from "@reactvision/react-viro";
<ViroNode onClick={exitVRScene}>
<ViroQuad ... />
<ViroText text="Exit" ... />
</ViroNode>
exitVRScene() finishes VRActivity and returns the user to the panel.
b. Hardware back button
ViroQuestEntryPoint wires the back/B button automatically — pressing it calls exitVRScene() and returns to the panel. No code required.
After the user returns to the panel, ViroXRSceneNavigator renders null (it owns no display on Quest), so the screen will be blank unless your app navigates away. Use onExitViro to handle this:
// ViroXRSceneNavigator
<ViroXRSceneNavigator
vrInitialScene={{ scene: MyVRScene }}
onExitViro={() => navigation.goBack()}
/>
// StudioSceneNavigator
<StudioSceneNavigator
onExitViro={() => navigation.goBack()}
/>
onExitViro fires when exitVRScene() is called — whether from the B button, a programmatic exitVRScene() call, or an in-scene exit button.
If you need different back behaviour (e.g. pop the scene stack instead of exiting VR entirely), register a custom VR root (see section 8) and wire BackHandler yourself.
c. System Meta button
When the user presses the Meta button, Horizon OS shows the universal menu. Closing the app from there finishes VRActivity directly. The generated VRActivity registers an Application.ActivityLifecycleCallbacks that auto-finishes itself when the panel resumes — so both surfaces are never alive simultaneously.
7. (Optional) VR-specific native operations
Renderer flags
passthroughEnabled and handTrackingEnabled are props on ViroXRSceneNavigator and flow through the bridge to ViroVRSceneNavigator automatically:
<ViroXRSceneNavigator
vrInitialScene={{ scene: MyVRScene }}
passthroughEnabled
handTrackingEnabled
hdrEnabled
bloomEnabled
/>
VRModuleOpenXR (recenter / passthrough toggle)
VRModuleOpenXR.recenterTracking(viewTag) and setPassthroughEnabled(viewTag, enabled) need the native view tag of the live ViroVRSceneNavigator. The library exports both the typed module reference and a useVRViewTag() hook that subscribes to it:
import { VRModuleOpenXR, useVRViewTag } from "@reactvision/react-viro";
function MyVRScene() {
const viewTag = useVRViewTag();
const recenter = () => {
if (viewTag != null) VRModuleOpenXR?.recenterTracking?.(viewTag);
};
return (
<ViroScene>
<ViroNode onClick={recenter}>...</ViroNode>
</ViroScene>
);
}
8. (Optional) Custom VR root
The library auto-registers ViroQuestEntryPoint as "VRQuestScene". If you need a fully custom VR root (custom navigator props, additional providers, analytics wrappers), re-register after importing the library — the last registration wins in React Native:
// index.js / App.tsx — after your normal imports
import "@reactvision/react-viro"; // ensures library side-effects run first
import { AppRegistry } from "react-native";
AppRegistry.registerComponent(
"VRQuestScene",
() => require("./components/vr-quest-root").default
);
Your custom root still uses ViroVRSceneNavigator directly and is responsible for subscribing to VRQuestNavigatorBridge if you want ViroXRSceneNavigator's push/pop calls to reach it:
import {
ViroVRSceneNavigator,
VRQuestNavigatorBridge,
} from "@reactvision/react-viro";
export default function VRQuestRoot() {
// ViroQuestEntryPoint does all of this automatically — only needed for
// custom roots that bypass it.
const [intent, setIntent] = useState(() => VRQuestNavigatorBridge.getIntent());
const navRef = useRef(null);
useEffect(() => VRQuestNavigatorBridge.onIntent(setIntent), []);
useEffect(() => {
if (!intent) return;
return VRQuestNavigatorBridge.subscribeOps((op) => {
if (op.type === "push") navRef.current?.push(op.scene);
else if (op.type === "pop") navRef.current?.pop();
// …etc
});
}, [intent?.intentKey]);
if (!intent) return null;
return (
<ViroVRSceneNavigator
ref={navRef}
key={intent.intentKey}
initialScene={intent.initialScene}
passthroughEnabled // ← custom prop example
style={{ flex: 1 }}
/>
);
}
Common pitfalls
- Using
ViroVRSceneNavigatordirectly in a panel screen (MainActivity) → the engine bindsxrCreateSessionto MainActivity, which lackscom.oculus.intent.category.VR. The session stays inIDLE, you see a black region, and logcat shows errors. UseViroXRSceneNavigatorfor panel screens;ViroVRSceneNavigatoris for OVR/Cardboard and for the VRActivity context only. - Rendering
ViroARSceneas the root of a VR scene → renders nothing on Quest. VR scenes must useViroSceneas root.StudioARScenehandles this automatically. - Calling
launchVRScene()from a component that's also rendered in VRActivity → don't. The launch belongs in the panel surface only. - Wrong APK on the Quest → if you ship without
xRMode: ["QUEST"]in the plugin config, no VRActivity is generated andVRLauncherwill be undefined at runtime. - Not wiring
onExitViro→ after B button / programmatic exit,ViroXRSceneNavigatorrendersnullin the panel, leaving a blank screen. Always passonExitViroto navigate back:onExitViro={() => navigation.goBack()}. - Expecting
onExitViroto fire on Meta button / system kill → it doesn't reliably; the system can finish VRActivity without going throughexitVRScene(). Don't gate critical cleanup on it. - Top-level
importof a heavy VR root inindex.js→ if you use a custom root, preferrequire(…)lazily inside the factory to avoid Viro native module access before the JS bridge is ready. ViroXRSceneNavigatorthrows on Quest with Expo 54 → VR requires Expo 55 / RN 0.83. The runtime gate fires beforelaunchVRScene()and surfaces a clear error. Either upgrade Expo, or scope your build to AR-only by omittingQUESTfromxRMode.- Stale
VRActivity.ktafter upgrading react-viro → the plugin won't overwrite an existingVRActivity.kt. If hot reload dies the moment VR launches and animations don't play on first onClick, you're still on the old no-op-delegate template. Delete the file and re-runexpo prebuild.
Reference example
The showcase app in Github/showcase/components/ar-examples/vr-quest-scene.tsx contains a single-file demo with both the launcher panel and the VR root, including controllers, particle effects, custom shaders, physics, and in-scene system controls (recenter, passthrough toggle, exit).
Updated 4 days ago