HorizonOS Setup Guide

This guide walks through setting up HoriznOS (OpenXR) support in a React Native (with Expo) app using ViroReact.

Requirements

PathMinimum Expo SDKMinimum React Native
AR (iOS / non-Quest Android)540.81
VR (Meta Quest)550.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 ViroQuestEntryPoint as 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 in AndroidManifest.xml declaring com.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.kt template implements lifecycle-correct onResume / onPause overrides that drive ReactHostImpl.onHostResume(VRActivity) and keep JavaTimerManager + Metro Fast Refresh alive while VR is foreground. Because the plugin only writes the file when missing, delete the existing android/app/src/main/java/<your-package>/VRActivity.kt and re-run npx expo prebuild --clean to pick up the fix. Without this step you will see broken requestAnimationFrame / 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 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 via ViroARSceneNavigator.
  • vrInitialScene — forwarded to VRActivity via the bridge; mounted inside ViroVRSceneNavigator in VRActivity.
  • initialScene — shorthand when AR and VR use the same scene component.

Platform behavior summary

ComponentiOSAndroid (non-Quest)Meta Quest
ViroXRSceneNavigatorAR (ViroARSceneNavigator)AR (ViroARSceneNavigator)Launches VRActivity, renders null in panel
ViroVRSceneNavigator(OVR/Cardboard only — not for Quest)OVR/Cardboard VRUsed internally by VRActivity
StudioSceneNavigatorAR + Studio contentAR + Studio contentVR + 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 ViroVRSceneNavigator directly in a panel screen (MainActivity) → the engine binds xrCreateSession to MainActivity, which lacks com.oculus.intent.category.VR. The session stays in IDLE, you see a black region, and logcat shows errors. Use ViroXRSceneNavigator for panel screens; ViroVRSceneNavigator is for OVR/Cardboard and for the VRActivity context only.
  • Rendering ViroARScene as the root of a VR scene → renders nothing on Quest. VR scenes must use ViroScene as root. StudioARScene handles 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 and VRLauncher will be undefined at runtime.
  • Not wiring onExitViro → after B button / programmatic exit, ViroXRSceneNavigator renders null in the panel, leaving a blank screen. Always pass onExitViro to navigate back: onExitViro={() => navigation.goBack()}.
  • Expecting onExitViro to fire on Meta button / system kill → it doesn't reliably; the system can finish VRActivity without going through exitVRScene(). Don't gate critical cleanup on it.
  • Top-level import of a heavy VR root in index.js → if you use a custom root, prefer require(…) lazily inside the factory to avoid Viro native module access before the JS bridge is ready.
  • ViroXRSceneNavigator throws on Quest with Expo 54 → VR requires Expo 55 / RN 0.83. The runtime gate fires before launchVRScene() and surfaces a clear error. Either upgrade Expo, or scope your build to AR-only by omitting QUEST from xRMode.
  • Stale VRActivity.kt after upgrading react-viro → the plugin won't overwrite an existing VRActivity.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-run expo 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).