
expo-ar: One React Native AR View for ARKit and ARCore

Stewart Moreland
If you've tried to add augmented reality to a React Native app, you already know the shape of the problem. AR on a phone is two completely separate native stacks — Apple's ARKit in Swift on iOS, Google's ARCore in Kotlin on Android — and React Native bridges neither of them for you. So you write both integrations by hand, invent your own event names and payload shapes for each side, and then lose an afternoon to the one bug where iOS fires an event Android spells slightly differently.
I built @stewmore/expo-ar so the next person doesn't have to start there. It's an MIT-licensed Expo native module that bridges ARKit and ARCore into one custom React Native view, behind a single shared TypeScript contract. It's out now as v0.1.0 — a foundation you can install, run on a device, and build real AR features on top of today.
"Didn't this already exist?"
Fair question, and the honest answer is: partly. There are two libraries any React Native developer reaches for first, and I want to be straight about why I didn't just use them — because the reason is fit, not quality.
ViroReact is the heavyweight, and it's genuinely good. It's a full declarative 3D rendering engine — <ViroARScene>, <Viro3DObject>, PBR lighting, particles, physics — sitting on top of native ARKit and ARCore. After a stretch in community-maintenance limbo it's now backed by ReactVision, MIT-licensed and actively developed again [1]. If you want a scene graph and a renderer handed to you, reach for it first. The trade-off is exactly that: you adopt its entire rendering engine and component model, and the richest persistence features (cloud and geospatial anchors) lean on its hosted Platform. That's a lot of surface area if all you actually need is to sense the world and place a few anchors with your own renderer.
@react-three/xr (the library formerly published as react-xr) is the other reflex, and it's excellent — for a different target. It renders react-three-fiber into a WebXR session [2]. That's the wrong layer for a native mobile AR app: iOS Safari doesn't ship usable WebXR AR, so on the platform half your users are on, you don't get ARKit at all. It shines for browser and headset (Quest) experiences. It is not a native ARKit/ARCore bridge.
So why a third library?
Lightweight and portable. expo-ar isn't trying to be a 3D engine and it isn't routed through the browser. It's a thin layer of native AR primitives — session, raycast, anchors, planes, depth — exposed behind one TypeScript contract, dropped into a standard Expo dev build. You bring whatever renderer and feature you want. The core stays small enough to read in one sitting.
If your project wants a full spatial engine, use Viro. If it lives in the browser, use @react-three/xr. If you want the raw AR plumbing for native iOS and Android and nothing you didn't ask for, that's the gap expo-ar fills.
What it is
expo-ar is deliberately use-case agnostic. Instead of shipping a "measuring app component" or a "furniture-placement widget," it exposes the generic primitives every AR feature is built from:
- Session lifecycle — start, pause, resume, reset, tied to the view and app state.
- Tracking — know when the world is actually being tracked before you trust a hit.
- Raycasting — the universal "screen point → 3D world point" operation.
- Anchors — persistent points in the world that survive as the camera moves.
- Plane detection — horizontal, vertical, or both.
- Depth / LiDAR — accurate hits on untextured walls and in low light, where the hardware supports it.
Measurement, tap-to-place objects, room scanning — those are composed on top of the primitives, not baked into the module. That keeps the core small and lets you build the exact experience you want.
One contract, both platforms
The thing I cared most about getting right: the Swift and Kotlin sides emit byte-for-byte identical event names and payload keys, and accept identical props and function signatures. The contract lives in one TypeScript file, and both native implementations are written to match it.
Drift between the two platforms is the number-one source of the "this event never fires on Android" bug. Writing both sides against a single canonical contract eliminates a whole category of it before it can happen.
A couple of conventions worth knowing up front:
Everything is in meters internally — you convert to display units (cm, ft, in) only at the UI edge. And poses cross the bridge as a 16-number, column-major 4×4 matrix, the same layout on both platforms, so the math you write works identically on iOS and Android.
What you get
Capability tiers — detect, don't assume
Not every device has the same AR hardware, so expo-ar is built around runtime detection. Call getCapabilities() before you mount the view and branch on the result:
| Tier | iOS | Android | What works |
|---|---|---|---|
| Best | LiDAR scene reconstruction | Depth API | Accurate depth on arbitrary surfaces, low light, untextured walls |
| Good | ARKit world tracking | ARCore, no depth | Tracking + planes on well-lit, textured surfaces |
| None | No ARKit | Not ARCore-supported | No AR — fall back to expo-camera |
The module never pretends LiDAR or Depth is present when it isn't. You decide what the experience degrades to.
The cardinal rule: the AR view owns the camera
This is the one that trips everyone up, so I'll say it plainly — I learned it the hard way, staring at a black rectangle with no error in the logs.
One AR session, app-wide. The AR view owns the camera.
An ARKit or ARCore session takes exclusive control of the camera. Stack the AR view on top of expo-camera, or run two AR sessions at once, and you get a silently black frame and no error to tell you why. expo-ar renders the camera feed and world-anchored 3D content natively; React Native draws its 2D HUD on top. Only one AR session runs app-wide, and expo-camera is only ever the non-AR fallback path.
Quick start
Install it with the Expo CLI, which wires up the autolinking:
npx expo install @stewmore/expo-ar
Add the config plugin to your app.json. It sets the iOS camera-usage description, the Android camera permission, and the ARKit/ARCore manifest entries:
{"expo": {"plugins": [["@stewmore/expo-ar",{"cameraPermission": "Allow $(PRODUCT_NAME) to use the camera for AR.","arRequired": false}]]}}
You need a development build and a real device
AR needs native code, so this runs in a development build — expo prebuild followed by EAS Build or a local build. Expo Go can't run it. Neither ARKit nor ARCore runs in the iOS Simulator or Android emulator either, so you'll test on a physical device.
From there it's just a view you drive through a ref:
import { useRef, useState } from 'react';import { View } from 'react-native';import {ExpoArView,getCapabilities,type ArViewHandle,type Capabilities,} from '@stewmore/expo-ar';export function ArScreen() {const ref = useRef<ArViewHandle>(null);const [caps] = useState<Capabilities>(() => getCapabilities());if (!caps.arSupported) {return <View /* expo-camera fallback */ />;}return (<ExpoArViewref={ref}style={{ flex: 1 }}planeDetection="both"depthEnabledonReady={(e) => console.log('AR ready', e.nativeEvent.capabilities)}// A tap raycasts and drops a persistent anchor at the hit point.onTap={async (e) => {await ref.current?.addAnchor(e.nativeEvent.x, e.nativeEvent.y);}}/>);}
That's the whole surface: a handful of props, six events (onReady, onTrackingStateChange, onTap, onAnchorsChange, onProjection, onError), and a ref handle for raycast, addAnchor, removeAnchor, pause/resume/reset, snapshot, and a couple of rendering helpers.
Two worked examples
The repo ships an example/ app with two features built entirely by composition over the core. Neither one adds a line of session or tracking code of its own — which is the whole point.
Measure
Tap surfaces to drop points and get a live measuring tape. The part I'm proud of is the object-pinned labels: per-segment length chips that stay stuck to the real-world points and track in 3D as you move the device. That's the opt-in emitProjections prop plus the onProjection event — the native side projects each anchor to screen space every frame, and the HUD pins a label at each segment's midpoint. The distance and area math is pure TypeScript over the anchor positions, in meters, formatted only at the UI edge.
Place
Tap surfaces to drop world-anchored 3D objects. This one adds addAnchor → attachModel, and reverses the order on cleanup: detach the model, then remove the anchor. Same primitives, completely different feature.
Both demos make the same argument: the core is generic, and the interesting work happens in a thin layer of composition on top of it. If you find yourself reaching back into the native module to build a feature, that's a signal the primitive is missing — open an issue.
Where it goes from here
v0.1.0 is a foundation, not a finish line, and I'd rather say that out loud than oversell it. The session lifecycle, raycasting, anchors, plane detection, depth/LiDAR, per-frame projection, and snapshot are all implemented and tested on both platforms. The web target is a deliberate no-AR stub that reports arSupported: false, so universal apps compile and degrade cleanly. Plane mesh geometry export and richer model formats are the next things on the list. It hasn't been hammered at scale yet — if it breaks on your device, that's the most useful issue you could file.
If you want to build AR into an Expo app — or you just want to see how an ARKit/ARCore bridge is actually put together — the code is open source, and AGENTS.md documents the architecture and the cross-platform contract discipline. Contributions, issues, and "this broke on my device" reports are all welcome.
Generic core, features composed on top. Build the AR you actually want.
Repo · Example app · Architecture notes