AR hit testing

A hit test asks the runtime where a ray from the device meets a surface it has actually recognised — and the answer arrives with an orientation, which is the part most first implementations throw away.

A reticle following a real surface

The device sweeps the room, casting a ray from the viewer space. The reticle sits on whatever surface the ray meets and tilts to match its normal. Turn anchors off and watch the placed objects slowly drift away from where they were put.

This demo needs WebGL, which your browser did not provide. The explanation below covers the same material on its own.

Loading demo…

What a hit test actually is

The device is continuously building a model of the room from its cameras and motion sensors. A hit test asks that model a question: if I cast this ray, where does it meet a surface you are confident about? The answer is a pose — position and orientation — and it comes from the runtime's understanding of the world, not from anything in your scene graph.

That has two consequences worth internalising before you write any code. First, results only exist where the device has recognised geometry, so an empty result is the normal state for the first few seconds of a session and for any surface the user has not looked at yet. Second, the answer is a full pose: a hit on a wall or a sloped table comes back oriented to that surface, and a reticle that ignores the orientation and always lies flat will visibly float through walls.

Hit testing is an optional feature named hit-test, and on the web it is currently an AR concern — you request it alongside an immersive-ar session. Sessions also usually want dom-overlay so that ordinary HTML controls stay usable on top of the camera feed.

Requesting a source, reading results

The asymmetry here trips people up: you request the hit test source once, asynchronously, outside the frame loop; you read results synchronously, every frame, from the frame object. Trying to await anything inside the frame callback is the wrong shape.

const session = await navigator.xr.requestSession('immersive-ar', {
  requiredFeatures: ['hit-test'],
  optionalFeatures: ['anchors', 'dom-overlay'],
  domOverlay: { root: document.getElementById('ar-ui') },
});

const viewerSpace = await session.requestReferenceSpace('viewer');
const localSpace = await session.requestReferenceSpace('local-floor');

// One source, created once. The ray defaults to -Z from the given space,
// which for 'viewer' means straight out of the device.
const hitTestSource = await session.requestHitTestSource({ space: viewerSpace });

function onFrame(time, frame) {
  // Synchronous. No await here -- the results are already computed for
  // this frame and asking again next frame is how you track movement.
  const results = frame.getHitTestResults(hitTestSource);
  if (results.length === 0) {
    reticle.visible = false;   // Normal, not an error.
    return;
  }

  // Results are ordered nearest-first along the ray.
  const pose = results[0].getPose(localSpace);
  if (!pose) return;

  reticle.visible = true;
  // The full matrix, not just the position -- this is what makes the
  // reticle lie flat on a table and stand upright on a wall.
  reticle.matrix.fromArray(pose.transform.matrix);
}

session.addEventListener('end', () => {
  // Hit test sources hold runtime resources and are not garbage collected
  // for you. Leaking them across sessions degrades tracking.
  hitTestSource.cancel();
});

requestHitTestSource can reject — the feature may be granted but unusable, for example before the device has any world model at all. Treat rejection as "no hit testing this session" and fall back, rather than failing the whole experience.

The pieces, and which space each one lives in

Almost every hit test bug is a space mix-up. Three different spaces are in play and they each answer a different question.

The source space (usually viewer)
Where the ray starts and which way it points. viewer means "out of the device", which is what you want for a phone held up at a table. Passing a controller's targetRaySpace instead gives you a hit test that follows the controller.
The result space (usually local-floor)
The space you resolve the result into, and the one your scene is built in. Getting a pose in viewer space instead produces content glued to the camera.
XRRay
An optional custom ray for the source: an origin and a direction, both relative to the source space. Omit it and you get the -Z ray, which is right most of the time.
Transient input hit tests
requestHitTestSourceForTransientInput handles the phone-tap case, where the input source only exists while a finger is down. Results arrive grouped per input source rather than as a flat list.
Anchors
A separate feature. createAnchor pins content to a point the runtime keeps correcting as it learns more about the room. Without one, content stays at the coordinates it was given while the room moves underneath it.

Why unanchored content drifts

The device's idea of where things are is an estimate, and it gets revised. As the user walks around, the runtime recognises that a wall it thought was 3.1 metres away is actually 3.0, and it corrects its whole world model. Anchored content is corrected along with it. Content you placed at a fixed coordinate is not — it stays where the old estimate said, which is now the wrong place.

This is why drift is so hard to catch in testing. Stand still and everything is perfect. Walk to the other side of the room and back, and the virtual mug is now hovering beside the table instead of on it. The fix is to call createAnchor on the hit test result and update your object from the anchor's pose every frame, rather than setting a position once and forgetting it.

Anchors are not free — each one costs the runtime tracking work, and platforms cap how many you can hold. Anchor the things that must stay put, not every particle.

What the demo above is doing

The room has exactly two recognised surfaces, the floor and the table, which is the honest version of what a real session gives you — the rest of the room exists but the runtime has no geometry for it. Restrict the recognised surfaces to just one and the reticle disappears whenever the ray points at the other, which is exactly how a real hit test behaves before the user has scanned an area.

The ray starts at the device and travels along its -Z, the same as a hit test source built on the viewer space. The reticle is oriented from the surface normal, so it lies flat on the floor and on the tabletop, and tilts on the table's sides — that tilt is the part you lose if you only copy the position out of the pose.

Objects are placed automatically every second and a half. With anchors on they stay exactly where they were put. Turn anchors off and new objects come out in a different colour and begin to wander — the demo has no real tracking to correct, so the drift is simulated, but the shape of the failure is what you will see on a phone.

Feature availability

hit-test and anchors are separate features and are not always granted together. Request anchors optionally and degrade to unanchored placement rather than failing the session.

Platformimmersive-arhit-testanchors
Android Chrome (ARCore)YesYesYes
Meta Quest 3 / Pro browserYesYesYes
visionOS SafariYesPartialPartial
iOS Safari (iPhone)NoNoNo
Desktop browsersNoNoNo

Checked 2026-09. iOS Safari still has no WebXR AR session; verify against caniuse and the WebXR hit test module before relying on any row.

Mistakes that cost the most time

The first three all look fine on a table in front of you and fall apart the moment someone walks around.

Using only the position from the pose
The pose carries orientation too. Drop it and the reticle lies flat on every surface, including walls, and placed objects ignore the slope of what they are standing on.
Placing without anchors
Works perfectly while standing still. Walk around and the content is no longer where you put it, because the runtime revised its model of the room and your object did not.
Treating an empty result list as an error
No results is the normal state before the device has recognised a surface. Hide the reticle and wait — do not log, retry, or tear down the source.
Requesting a hit test source per frame
It is an async call that allocates runtime resources. Request one at session start and reuse it; requesting inside the loop stalls the frame and leaks.
Never calling cancel()
Sources outlive the frame loop. Cancel on session end, or repeated sessions in the same page accumulate them and tracking quality degrades.
Resolving results into viewer space
Content ends up parented to the camera and follows the user around, which looks like a physics bug and is a one-word fix.

Further reading

The hit test module and the anchors module are separate specs, and reading them in that order is the shortest path to a placement flow that survives someone walking around.