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

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

June 4, 2026
9 min read
Stewart Moreland

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.

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.

Loading diagram...

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:

💡 Two conventions that keep your math portable

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:

TieriOSAndroidWhat works
BestLiDAR scene reconstructionDepth APIAccurate depth on arbitrary surfaces, low light, untextured walls
GoodARKit world trackingARCore, no depthTracking + planes on well-lit, textured surfaces
NoneNo ARKitNot ARCore-supportedNo 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.

Quick start

Install it with the Expo CLI, which wires up the autolinking:

bash
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:

app.json — config plugin
{
"expo": {
"plugins": [
[
"@stewmore/expo-ar",
{
"cameraPermission": "Allow $(PRODUCT_NAME) to use the camera for AR.",
"arRequired": false
}
]
]
}
}

From there it's just a view you drive through a ref:

ArScreen.tsx — mount the view, drop an anchor on tap
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 (
<ExpoArView
ref={ref}
style={{ flex: 1 }}
planeDetection="both"
depthEnabled
onReady={(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 addAnchorattachModel, 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